Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84658102e0 | |||
| 6529702f73 | |||
| fa0e9a93af | |||
| 9343486705 | |||
| 5342170ced | |||
| cc0e34b3d1 | |||
| e39f15d3d8 | |||
| bbcbf8d0f7 | |||
| 620f264861 | |||
| a50e5a7d69 | |||
| f095e6a2d1 | |||
| bf3e1c353d | |||
| c85ff602d0 | |||
| d73e1dcf0b | |||
| 0a54d0d302 | |||
| ae9e877445 | |||
| 853b205c46 | |||
| 8c4cc20551 | |||
| da77f083fe | |||
| 71feaf0175 | |||
| 65b9f0f395 | |||
| b41f943407 | |||
| 9debd77392 | |||
| 20dfe06866 | |||
| a0f630fb81 | |||
| e640215502 | |||
| 972b407956 | |||
| cf7008fd54 | |||
| 2747ca7754 | |||
| 0341540751 | |||
| 3bb9e936c3 | |||
| aef21d1b50 | |||
| a6fcde58cf | |||
| d839fa98a0 | |||
| a92ab85bc0 | |||
| 57b127ea95 | |||
| 56f42755cc | |||
| 15152714fd | |||
| 19b9c97e64 | |||
| 55a5577f2a | |||
| 6139095444 | |||
| 4c9985e01a | |||
| fecb96b04e | |||
| c98b81c8bd | |||
| 90bf411e0c | |||
| 867e86615e | |||
| 2cbe3c6b76 | |||
| 59adcbc36e | |||
| 6865b860bc | |||
| 9aaff5c368 | |||
| ce6f9b0459 | |||
| 976d6527b6 | |||
| 6a8ca90fd3 | |||
| 2d5958006c | |||
| 9faf8c0ea3 | |||
| cb0e61ea36 | |||
| bd3b28921d | |||
| 1d9e86760b | |||
| ae3bf70008 | |||
| fb719141fa | |||
| e45c566fd7 | |||
| 81d3e0045f | |||
| 1f3b2adbfe | |||
| 5b84e091b0 | |||
| ad024ab87b | |||
| cc4f420482 | |||
| a435c506f7 | |||
| c8c46cb9fb | |||
| 0354531e95 | |||
| c0dd93a1ab | |||
| 3c4c24d0e4 | |||
| e535da7f91 | |||
| b2d941a767 | |||
| fce8a9ece6 | |||
| 42d01da7f7 | |||
| 43cb3a0d17 | |||
| 6cf01f5530 | |||
| 6bf8098265 | |||
| a42392fec6 | |||
| a368636ec4 | |||
| f23117ff7a | |||
| 2cf163dfff | |||
| 6ff97ed246 | |||
| ef89207d9d | |||
| fa5805c6a4 | |||
| 57bab01855 | |||
| 0e22c5af15 | |||
| 2550722446 | |||
| 716d98e531 | |||
| 27648c78b3 | |||
| fa72cfb5ec | |||
| b3edd442f5 | |||
| e4be334ad4 | |||
| bdd78407bb | |||
| 37c8229061 | |||
| d1496a76e2 | |||
| 80d43fbaa8 | |||
| c1fb55c5b8 | |||
| 742f34f6eb | |||
| 2b089871a0 | |||
| e8ffd7f7dd | |||
| dd7e3e2a90 | |||
| 557137421d | |||
| 71880c6298 | |||
| 6b995a136d | |||
| fa3c625fb8 | |||
| 57b3254f70 | |||
| eb321312dc | |||
| ddcfbda0c2 | |||
| ffd9e84a00 | |||
| 2a674c6bcd | |||
| da0898f52a | |||
| 5d7b126c5e | |||
| 224ec33a14 | |||
| cc8de12740 | |||
| a05aaf8d71 | |||
| 9751dbf98d | |||
| 29d7bdb536 | |||
| 6c847a26d3 | |||
| 7b4ba86435 | |||
| 28f2aa17d8 | |||
| aa8a6a49f4 | |||
| 341c619d3d | |||
| 0ffe62e1ca | |||
| 925d0c7735 | |||
| f88c81cc59 | |||
| 121c49b35b | |||
| a4004f5e7a | |||
| fd0d5cf6ec | |||
| 1f293ae70b | |||
| 352118b4af | |||
| e33f1debc0 | |||
| f41d14b2a9 | |||
| f5ef2c5991 | |||
| 240fc0d3b6 | |||
| c3ae3a80de | |||
| 94fe0dea4d | |||
| 3c3382401c | |||
| c86694fc8f | |||
| dcf613446e | |||
| 0d4b169c79 | |||
| 972c1b81ec | |||
| 7ff29b669c | |||
| 0dbf0e0899 | |||
| 67a8610843 | |||
| cafa608e8c | |||
| 35d50fad0a | |||
| 4af36fc926 | |||
| 0ea9b769a3 | |||
| abdd0da306 | |||
| 80a6d559c9 | |||
| 55e4aedfca | |||
| 82f8fbccae | |||
| 8c829f9651 | |||
| a510fdb83c | |||
| 5fd55b4e1a | |||
| cc1e8af761 | |||
| 251c7376c9 | |||
| 8ba1ee4f56 | |||
| 37c1b64982 | |||
| 49327cf9ae | |||
| 898ca9199b | |||
| 2522bb3514 | |||
| bdbbcabfbc | |||
| 8eb49f64b6 | |||
| 6b8c215497 | |||
| 11749a428c | |||
| e83afde5ef | |||
| 031877e6a0 | |||
| 663826e295 | |||
| 14de5e4644 | |||
| 44cff5bf41 | |||
| bdfb042527 | |||
| b608fa0337 | |||
| 1f41114482 | |||
| 8fa20c6990 | |||
| d8584d0511 | |||
| 1eadb30481 | |||
| 8f96a5f296 | |||
| 6675e5dcfe | |||
| 54647ab7cf | |||
| 8b81f16dac | |||
| 1f4aa19a72 | |||
| 43839fd8e3 | |||
| 24b7797b60 | |||
| de8563704a | |||
| 83049786eb | |||
| 2137f65766 | |||
| 0ddb7a407e | |||
| 5ed547579d | |||
| 2bef9f9dee | |||
| fdecdd979b | |||
| e97bd3c67b | |||
| 0c8d5d8a4a | |||
| 268238215e | |||
| 31ff973eff | |||
| 1327c8e466 | |||
| c10d20d9f5 | |||
| d50a6fe19c | |||
| 6e3a98ae75 | |||
| 57e1db18e2 | |||
| c03e4620ea | |||
| e5b2fc8ea4 | |||
| 85bac5994e | |||
| f28d94d9a3 | |||
| 1574cce788 | |||
| b48332f075 | |||
| 3edc98b8d6 | |||
| c6b5aaf3de | |||
| c0478ed1b2 | |||
| b146a180d0 | |||
| bafa03aac2 | |||
| 59b096a60a | |||
| d09b0ab40a | |||
| 137d40affe | |||
| 84e4201dc1 | |||
| b75cd18580 | |||
| 36d183cba9 | |||
| b1f81eff0f | |||
| 78a8737f29 | |||
| d5542abd27 | |||
| 980e20247c | |||
| ba43a3d6bd | |||
| b44d370b51 | |||
| 6e81180175 | |||
| 5ae6af114e | |||
| c97f232290 | |||
| 6d35ce06e0 | |||
| 6d6969190a | |||
| fe73589633 | |||
| 6baf643645 | |||
| 570eca0b82 | |||
| b04b52e3f1 | |||
| eda9770f49 | |||
| efa66b9fd6 | |||
| fd8335b683 | |||
| 4c1b11c3cc | |||
| c6d1a7ffed | |||
| 9b0e778392 | |||
| d31f108821 | |||
| b590513f94 | |||
| ebb73492c4 | |||
| 8b526aa13b | |||
| 67b816cd61 | |||
| faaca310e9 | |||
| 0957f83411 | |||
| 6463c6b3d1 | |||
| bd9868b4e1 | |||
| 07e5b185d7 | |||
| 6140808efb |
@@ -6,10 +6,19 @@ node_modules/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
client/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
tests/
|
||||
vitest.config.js
|
||||
.markdownlint.json
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea/
|
||||
docs/
|
||||
@@ -1,22 +0,0 @@
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
|
||||
# Emby Configuration (single instance)
|
||||
EMBY_URL=http://localhost:8096
|
||||
EMBY_API_KEY=your_emby_api_key
|
||||
|
||||
# SABnzbd Instances (JSON array)
|
||||
# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}]
|
||||
SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}]
|
||||
|
||||
# Sonarr Instances (JSON array)
|
||||
SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}]
|
||||
|
||||
# Radarr Instances (JSON array)
|
||||
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
|
||||
|
||||
# qBittorrent Instances (JSON array)
|
||||
QBITTORRENT_INSTANCES=[
|
||||
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
|
||||
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
|
||||
]
|
||||
+62
@@ -14,6 +14,56 @@ PORT=3001
|
||||
# - silent: No logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Cookie signing secret for tamper-proof session cookies
|
||||
# Required in production (server exits on startup if unset).
|
||||
# Generate with: openssl rand -hex 32
|
||||
COOKIE_SECRET=your-cookie-secret-here
|
||||
|
||||
# =============================================================================
|
||||
# TLS / HTTPS
|
||||
# =============================================================================
|
||||
|
||||
# TLS is enabled by default using the bundled snakeoil self-signed certificate
|
||||
# (valid for localhost/127.0.0.1, 10-year expiry).
|
||||
# Set TLS_CERT and TLS_KEY to use your own certificate (recommended).
|
||||
# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy).
|
||||
#
|
||||
# To generate a self-signed cert for your own hostname:
|
||||
# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \
|
||||
# -days 365 -nodes -subj "/CN=yourhostname" \
|
||||
# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x"
|
||||
#
|
||||
# TLS_ENABLED=true
|
||||
# TLS_CERT=/path/to/server.crt
|
||||
# TLS_KEY=/path/to/server.key
|
||||
|
||||
# =============================================================================
|
||||
# REVERSE PROXY & DEPLOYMENT
|
||||
# =============================================================================
|
||||
|
||||
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik).
|
||||
# This makes Express trust X-Forwarded-For and X-Forwarded-Proto so that
|
||||
# req.ip reflects the real client IP and cookies are marked secure correctly.
|
||||
# Leave unset if sofarr is exposed directly to the internet.
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# Directory for persistent data (SQLite token store, server logs).
|
||||
# Must be writable by the process user (UID 1000 in the container).
|
||||
# Defaults to ./data relative to the project root.
|
||||
# DATA_DIR=/app/data
|
||||
|
||||
# Number of days of completed download history to show in the Recently Completed section.
|
||||
# Override per-request with ?days=N (capped at 90).
|
||||
# RECENT_COMPLETED_DAYS=7
|
||||
|
||||
# Background polling interval in milliseconds (default: 5000)
|
||||
# sofarr polls all services in the background and caches results so
|
||||
# dashboard requests are near-instant.
|
||||
# Set to 0, "off", "false", or "disabled" to disable background polling.
|
||||
# When disabled, data is fetched on-demand when a user opens the dashboard
|
||||
# and cached for 30 seconds so other users benefit from the same fetch.
|
||||
# POLL_INTERVAL=5000
|
||||
|
||||
# =============================================================================
|
||||
# EMBY (Authentication - Required)
|
||||
# =============================================================================
|
||||
@@ -44,6 +94,17 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
|
||||
# QBITTORRENT_USERNAME=admin
|
||||
# QBITTORRENT_PASSWORD=your-password
|
||||
|
||||
# =============================================================================
|
||||
# RTORRENT_INSTANCES (JSON Array)
|
||||
# The url MUST include the full XML-RPC endpoint path.
|
||||
# Standard/self-hosted installs: .../RPC2
|
||||
# whatbox.ca users: .../xmlrpc
|
||||
# Other installations may use different custom paths.
|
||||
# Example:
|
||||
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent.local:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
|
||||
# For whatbox.ca:
|
||||
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
|
||||
|
||||
# =============================================================================
|
||||
# SONARR INSTANCES (JSON Array Format)
|
||||
# Add one or more Sonarr instances as a single-line JSON array
|
||||
@@ -74,4 +135,5 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# 3. URLs should include protocol (http:// or https://)
|
||||
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
||||
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
||||
# 6. Background polling keeps data fresh; disable it for low-resource setups
|
||||
# =============================================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
- 'develop*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -12,25 +13,33 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: version
|
||||
- name: Compute image tags
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Building version ${VERSION} from branch ${BRANCH}"
|
||||
|
||||
if [[ "$BRANCH" == develop* ]]; then
|
||||
# Sanitise branch name for tag: replace slashes with dashes
|
||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
||||
else
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
||||
fi
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
|
||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
|
||||
reg.i3omb.com/sofarr:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run security audit (fail on high+)
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: Check for critical vulnerabilities
|
||||
run: npm audit --audit-level=critical --json | jq -e '.metadata.vulnerabilities.critical == 0' || (echo "Critical vulnerabilities found!" && exit 1)
|
||||
continue-on-error: false
|
||||
|
||||
test:
|
||||
name: Tests & coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
# Required by tokenStore (writable temp dir in CI)
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
# Disable rate limiters so integration tests don't hit 429s
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Docs Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "**.md"
|
||||
- ".gitea/workflows/docs-check.yml"
|
||||
|
||||
jobs:
|
||||
markdown-lint:
|
||||
name: Markdown lint
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install markdownlint-cli
|
||||
run: npm install -g markdownlint-cli
|
||||
|
||||
- name: Lint all Markdown files
|
||||
run: markdownlint "**/*.md" --ignore node_modules
|
||||
|
||||
mermaid-parse:
|
||||
name: Mermaid diagram parse check
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install mermaid and jsdom
|
||||
run: npm install mermaid jsdom
|
||||
|
||||
- name: Extract and validate Mermaid diagrams
|
||||
run: |
|
||||
cat > check-mermaid.cjs << 'SCRIPT'
|
||||
const { JSDOM } = require('jsdom');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Provide minimal browser globals so mermaid.parse() works in Node
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost' });
|
||||
globalThis.window = dom.window;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.DOMPurify = {
|
||||
addHook: () => {}, removeHook: () => {}, setConfig: () => {},
|
||||
sanitize: (s) => s, isValidAttribute: () => true,
|
||||
};
|
||||
|
||||
function findMdFiles(dir) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
|
||||
out.push(...findMdFiles(full));
|
||||
else if (e.isFile() && e.name.endsWith('.md'))
|
||||
out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
import('./node_modules/mermaid/dist/mermaid.core.mjs').then(async (m) => {
|
||||
const mermaid = m.default;
|
||||
let errors = 0, total = 0;
|
||||
|
||||
for (const mdFile of findMdFiles('.')) {
|
||||
const content = fs.readFileSync(mdFile, 'utf8');
|
||||
const blocks = [...content.matchAll(/^```mermaid\n([\s\S]*?)^```/gm)];
|
||||
if (!blocks.length) continue;
|
||||
console.log(`\nChecking ${mdFile} (${blocks.length} diagram(s))`);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
total++;
|
||||
const diagram = blocks[i][1].trim();
|
||||
try {
|
||||
await mermaid.parse(diagram);
|
||||
console.log(` [OK] diagram ${i + 1}`);
|
||||
} catch (err) {
|
||||
const msg = String(err.message || err).split('\n')[0];
|
||||
console.error(` [FAIL] diagram ${i + 1}: ${msg}`);
|
||||
console.log(`::warning file=${mdFile}::Mermaid diagram ${i + 1} failed: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${total}. Failures: ${errors}`);
|
||||
if (errors > 0) {
|
||||
console.log(`::warning::${errors} Mermaid diagram(s) failed to parse.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}).catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||
SCRIPT
|
||||
node check-mermaid.cjs
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Licence Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.jsx"
|
||||
- "**/*.tsx"
|
||||
pull_request:
|
||||
branches: ["**", "!main", "!release/**"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".gitea/workflows/licence-check.yml"
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.jsx"
|
||||
- "**/*.tsx"
|
||||
|
||||
jobs:
|
||||
licence-check:
|
||||
name: Licence compatibility and copyright header verification
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install production dependencies
|
||||
run: npm ci --omit=dev
|
||||
|
||||
- name: Check licence compatibility
|
||||
run: |
|
||||
npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--excludePrivatePackages \
|
||||
&& echo "All production dependency licences are compatible with MIT."
|
||||
|
||||
- name: Check copyright headers in source files
|
||||
run: |
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Find all source files, excluding build artifacts and node_modules
|
||||
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
|
||||
! -path "./node_modules/*" \
|
||||
! -path "./.git/*" \
|
||||
! -path "./dist/*" \
|
||||
! -path "./build/*" \
|
||||
! -path "./.gitea/*")
|
||||
|
||||
MISSING_HEADER=0
|
||||
|
||||
# Check each file for MIT-compliant copyright header
|
||||
while IFS= read -r file; do
|
||||
if [ -z "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
|
||||
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
|
||||
echo "❌ Missing MIT-compliant copyright header in: $file"
|
||||
echo " Required format: // Copyright (c) YYYY Name. MIT License."
|
||||
MISSING_HEADER=$((MISSING_HEADER + 1))
|
||||
fi
|
||||
done <<< "$SOURCE_FILES"
|
||||
|
||||
if [ $MISSING_HEADER -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All source files have MIT-compliant copyright headers."
|
||||
fi
|
||||
@@ -1,6 +1,12 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD009": false,
|
||||
"MD012": false,
|
||||
"MD013": false,
|
||||
"MD022": false,
|
||||
"MD024": false,
|
||||
"MD029": false,
|
||||
"MD031": false,
|
||||
"MD032": false,
|
||||
"MD033": false,
|
||||
"MD034": false,
|
||||
"MD036": false,
|
||||
"MD040": false,
|
||||
"MD041": false,
|
||||
"MD058": false,
|
||||
"MD060": false
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **History tab** — new "Recently Completed" tab showing imported and failed downloads from Sonarr/Radarr history for the last N days (configurable via the days input, persisted in `localStorage`). Auto-refreshes every 5 minutes.
|
||||
- **History deduplication** — when a failed download has subsequently been imported successfully, only the successful record is shown. If the most recent record for an item is a failure but the episode/movie is already on disk (upgrade attempt), the record is flagged as `availableForUpgrade`.
|
||||
- **"Upgrade available" badge** — failed history cards where the content is already on disk display an amber badge to indicate this is a failed upgrade rather than a missing item.
|
||||
- **"Hide upgrade failures" toggle** — checkbox in the history tab to filter out failed records that are already available on disk. State persists in `localStorage`. Tooltip explains the behaviour and matches the episode/multi-episode tooltip style.
|
||||
- **Blocklist & Search button** — admin-only button on download cards with an "Import Pending" caution. Removes the download from the client with `blocklist=true` (preventing re-grab of the same release) then immediately triggers an `EpisodeSearch`/`MoviesSearch` command in Sonarr/Radarr. Shows a confirmation dialog, loading/success/error states. Kicks a background poll on success.
|
||||
- **`POST /api/dashboard/blocklist-search`** — new admin-only endpoint backing the above button. Accepts `arrQueueId`, `arrType`, `arrInstanceUrl`, `arrInstanceKey`, `arrContentId`, `arrContentType`.
|
||||
- **Title link home navigation** — the sofarr logo/title in the header now navigates to the default view (Active Downloads tab, close status panel, reset "Show all users" toggle) without a page reload.
|
||||
- **Version footer link** — the version string in the dashboard footer links to the source repository.
|
||||
|
||||
### Changed
|
||||
|
||||
- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned.
|
||||
- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style.
|
||||
- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup.
|
||||
- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability.
|
||||
- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download.
|
||||
- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.2] - 2026-05-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **Header logo** — uses the higher-resolution 192px favicon source rendered at 56px for better visual balance alongside the title text.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2025-05-17
|
||||
|
||||
### Security
|
||||
|
||||
- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`).
|
||||
- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters.
|
||||
- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration.
|
||||
- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages.
|
||||
- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`.
|
||||
|
||||
### Compliance
|
||||
|
||||
- **MIT LICENSE file** added to project root.
|
||||
- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`).
|
||||
- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure.
|
||||
|
||||
### Configuration
|
||||
|
||||
- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing.
|
||||
|
||||
### Docker / Deployment
|
||||
|
||||
- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A.
|
||||
- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image.
|
||||
|
||||
### CI
|
||||
|
||||
- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release.
|
||||
- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.2] - 2025-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Server startup message now includes the current version (`sofarr v1.1.2`).
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] - 2025-05-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes.
|
||||
- **Episode tooltip** — solid background colour (theme-dependent) for readability.
|
||||
- Sonarr queue and history API requests now include `includeEpisode=true`.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
- SABnzbd queue and history integration.
|
||||
- qBittorrent torrent integration.
|
||||
- Sonarr and Radarr queue/history matching with user tag filtering.
|
||||
- Emby/Jellyfin authentication.
|
||||
- Server-Sent Events (SSE) real-time dashboard.
|
||||
- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy.
|
||||
- Background polling with configurable interval and on-demand fallback.
|
||||
- Docker multi-stage build, non-root user, read-only filesystem.
|
||||
- TLS support with bundled snakeoil certificate.
|
||||
+40
-8
@@ -1,4 +1,18 @@
|
||||
FROM node:18-alpine
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — deps: install production dependencies only
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# All dependencies are pure JavaScript — no native addons, no build tools.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime image (minimal attack surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="sofarr"
|
||||
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
|
||||
@@ -9,18 +23,36 @@ LABEL org.opencontainers.image.vendor="Gordon Bolton"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL custom.hardware.requirement="None - runs on any Docker-supported platform including ARM and x86_64"
|
||||
|
||||
# Use the built-in non-root 'node' user (UID 1000) from the official image
|
||||
# The /app directory is owned by root; data directory is owned by node
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
# Copy production deps from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy application source
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
# Copy application source owned by root (read-only at runtime)
|
||||
COPY --chown=root:root server/ ./server/
|
||||
COPY --chown=root:root public/ ./public/
|
||||
COPY --chown=root:root package.json ./
|
||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||
COPY --chown=root:root certs/ ./certs/
|
||||
|
||||
# Persistent data directory owned by node user (token store, logs)
|
||||
RUN mkdir -p /app/data && chown node:node /app/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATA_DIR=/app/data
|
||||
|
||||
# Drop to non-root user for all subsequent operations
|
||||
USER node
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# HEALTHCHECK — Docker will restart the container if this fails 3 times.
|
||||
# Respects TLS_ENABLED at runtime: uses https (with --no-check-certificate
|
||||
# to handle self-signed/snakeoil certs) when TLS is on, plain http when off.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD /bin/sh -c '[ "${TLS_ENABLED:-true}" = "false" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health'
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gordon Bolton
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -7,8 +7,9 @@
|
||||
## What It Does
|
||||
|
||||
sofarr connects to your media stack and shows you a personalized view of:
|
||||
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
|
||||
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent, Transmission, rTorrent)
|
||||
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
|
||||
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
|
||||
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
||||
- **Multi-Instance Support** - Connect to multiple instances of each service
|
||||
|
||||
@@ -20,7 +21,9 @@ sofarr connects to your media stack and shows you a personalized view of:
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
|
||||
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │
|
||||
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
|
||||
└─────────────┘ └──────────────┘ │ Sonarr (TV management) │
|
||||
└─────────────┘ └──────────────┘ │ Transmission (Torrents) │
|
||||
│ │ rTorrent (Torrents) │
|
||||
│ │ Sonarr (TV management) │
|
||||
│ │ Radarr (Movie management) │
|
||||
│ │ Emby (User authentication) │
|
||||
▼ └─────────────────────────────┘
|
||||
@@ -33,10 +36,10 @@ sofarr connects to your media stack and shows you a personalized view of:
|
||||
### The Matching Process
|
||||
|
||||
1. **User Authentication**: Login via Emby credentials
|
||||
2. **Tag-Based Matching**:
|
||||
2. **Tag-Based Matching**:
|
||||
- Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon")
|
||||
- sofarr checks Sonarr/Radarr activity to find items tagged with your name
|
||||
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity
|
||||
- Downloads (from SABnzbd, qBittorrent, Transmission, or rTorrent) are matched by title to that activity
|
||||
- Only your downloads appear on your dashboard
|
||||
|
||||
### Multi-Instance Support
|
||||
@@ -51,8 +54,8 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** (recommended), or Node.js (v12+) for manual installation
|
||||
- At least one of: SABnzbd or qBittorrent
|
||||
- **Docker** (recommended), or Node.js (v22+) for manual installation
|
||||
- At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
|
||||
- Sonarr (optional, for TV tracking)
|
||||
- Radarr (optional, for movie tracking)
|
||||
- Emby (for user authentication)
|
||||
@@ -92,6 +95,28 @@ docker run -d \
|
||||
|
||||
3. **Access the dashboard** at `http://your-server:3001`
|
||||
|
||||
### Using Environment Variables (Alternative to .env file)
|
||||
|
||||
All configuration can be passed directly as environment variables instead of mounting a `.env` file. This is the preferred approach for orchestrated deployments (Docker Compose, Kubernetes, Portainer, etc).
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name sofarr \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3001 \
|
||||
-e EMBY_URL=http://emby.local:8096 \
|
||||
-e EMBY_API_KEY=your-emby-api-key \
|
||||
-e SONARR_INSTANCES='[{"name":"main","url":"http://sonarr:8989","apiKey":"your-key"}]' \
|
||||
-e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \
|
||||
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
|
||||
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
|
||||
-e TRANSMISSION_INSTANCES='[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]' \
|
||||
-e RTORRENT_INSTANCES='[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]' \
|
||||
-e LOG_LEVEL=info \
|
||||
-e POLL_INTERVAL=5000 \
|
||||
docker.i3omb.com/sofarr:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
@@ -103,17 +128,28 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- /opt/sofarr/.env:/app/.env
|
||||
environment:
|
||||
- EMBY_URL=http://emby:8096
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"http://sonarr:8989","apiKey":"your-key"}]
|
||||
- RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]
|
||||
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
|
||||
- TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]
|
||||
- RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
|
||||
- LOG_LEVEL=info
|
||||
- POLL_INTERVAL=5000
|
||||
```
|
||||
|
||||
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
|
||||
|
||||
### Available Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `0.1` | Latest patch for the 0.1.x release line |
|
||||
| `0.1.0` | Specific version |
|
||||
| `1.0` | Latest patch for the 1.0.x release line |
|
||||
| `1.0.0` | Specific version |
|
||||
|
||||
### Updating
|
||||
|
||||
@@ -154,8 +190,23 @@ Open `http://localhost:3001` in your browser
|
||||
```bash
|
||||
PORT=3001 # Server port
|
||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
```
|
||||
|
||||
### Download Clients (PDCA)
|
||||
|
||||
sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management.
|
||||
|
||||
**Supported Download Clients:**
|
||||
|
||||
| Client | Protocol | Auth Method | Notes |
|
||||
|--------|----------|-------------|-------|
|
||||
| SABnzbd | REST API | API Key | Usenet downloads |
|
||||
| qBittorrent | Sync API | Username/Password | BitTorrent with incremental updates |
|
||||
| Transmission | JSON-RPC | Username/Password | BitTorrent with session management |
|
||||
| rTorrent | XML-RPC | HTTP Basic Auth | BitTorrent, requires the full RPC endpoint in the url field (e.g. /RPC2 or /xmlrpc for whatbox.ca). No path is automatically appended. |
|
||||
|
||||
### Service Instances (JSON Array Format)
|
||||
|
||||
All services support multi-instance configuration via single-line JSON arrays:
|
||||
@@ -167,10 +218,21 @@ SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey
|
||||
# qBittorrent Instances (uses username/password, not API key)
|
||||
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
|
||||
|
||||
# Transmission Instances (uses username/password)
|
||||
TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091/transmission/rpc","username":"admin","password":"pass"}]
|
||||
|
||||
# rTorrent Instances (uses username/password, URL must include full RPC endpoint)
|
||||
# Standard installs use /RPC2. Some providers like whatbox.ca use /xmlrpc.
|
||||
# No path is automatically appended - always include the full RPC endpoint.
|
||||
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
|
||||
|
||||
# For whatbox.ca (example):
|
||||
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
|
||||
|
||||
# Sonarr Instances
|
||||
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
|
||||
|
||||
# Radarr Instances
|
||||
# Radarr Instances
|
||||
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
|
||||
|
||||
# Emby (single instance for authentication)
|
||||
@@ -184,6 +246,18 @@ If you only have one instance, you can use the legacy format:
|
||||
```bash
|
||||
SABNZBD_URL=https://sabnzbd.example.com
|
||||
SABNZBD_API_KEY=your-api-key
|
||||
|
||||
QBITTORRENT_URL=https://qbittorrent.example.com
|
||||
QBITTORRENT_USERNAME=admin
|
||||
QBITTORRENT_PASSWORD=secret
|
||||
|
||||
TRANSMISSION_URL=http://transmission:9091/transmission/rpc
|
||||
TRANSMISSION_USERNAME=admin
|
||||
TRANSMISSION_PASSWORD=pass
|
||||
|
||||
RTORRENT_URL=http://rtorrent:8080/RPC2
|
||||
RTORRENT_USERNAME=rtorrent
|
||||
RTORRENT_PASSWORD=rtorrent
|
||||
```
|
||||
|
||||
## Setting Up User Tags
|
||||
@@ -204,9 +278,22 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Background Polling
|
||||
|
||||
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
|
||||
|
||||
| Setting | Behaviour |
|
||||
|---------|----------|
|
||||
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
|
||||
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
|
||||
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
|
||||
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser opens the SSE stream it triggers a fresh poll; the fetched data is cached and served to all other connected clients until the next poll.
|
||||
|
||||
### Real-Time Updates
|
||||
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
||||
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
|
||||
- In-place DOM updates for smooth UI (no flickering)
|
||||
- Browser reconnects automatically on network interruption
|
||||
|
||||
### Download Information Displayed
|
||||
- **Progress bar** with visual completion percentage
|
||||
@@ -219,23 +306,32 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||
### For qBittorrent Downloads
|
||||
- **Seeds** - Number of seeders
|
||||
- **Peers** - Number of peers
|
||||
- **Availability** - Percentage available in swarm
|
||||
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/auth/login` - Login with Emby credentials
|
||||
- `POST /api/auth/logout` - Logout and clear session
|
||||
- `POST /api/auth/login` — Login with Emby credentials
|
||||
- `POST /api/auth/logout` — Logout and revoke session
|
||||
- `GET /api/auth/me` — Check current session
|
||||
- `GET /api/csrf` — Fetch a CSRF token
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
|
||||
- `GET /api/dashboard/stream` — **SSE stream**: pushes `{ user, isAdmin, downloads }` on every poll cycle
|
||||
- `GET /api/dashboard/user-downloads` — Single-request download fetch (no streaming)
|
||||
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
||||
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
||||
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
||||
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%)
|
||||
|
||||
### History
|
||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` - SABnzbd API proxy
|
||||
- `GET /api/qbittorrent/*` - qBittorrent API proxy
|
||||
- `GET /api/sonarr/*` - Sonarr API proxy
|
||||
- `GET /api/radarr/*` - Radarr API proxy
|
||||
- `GET /api/emby/*` - Emby API proxy
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||
- `GET /api/radarr/*` — Radarr API proxy
|
||||
- `GET /api/emby/*` — Emby API proxy
|
||||
|
||||
## Logging Levels
|
||||
|
||||
@@ -265,6 +361,17 @@ Logs are written to both console and `server.log` file.
|
||||
- Check qBittorrent Web UI is enabled
|
||||
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # run all tests once
|
||||
npm run test:watch # watch mode
|
||||
npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -282,3 +389,4 @@ MIT
|
||||
---
|
||||
|
||||
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
|
||||
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
# Security Policy & Hardening Guide
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities.
|
||||
Email: gordon@i3omb.com — expect acknowledgement within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
sofarr is a personal dashboard intended for a small trusted group (household/team).
|
||||
It proxies requests to *arr stack services using stored API keys and authenticates
|
||||
users via Emby. The primary threat surface when exposed to the public internet:
|
||||
|
||||
| Threat | Mitigations |
|
||||
|--------|-------------|
|
||||
| Credential brute-force | Rate limiting (10 fails/15 min per IP), account lockout window |
|
||||
| Session hijacking | HMAC-signed cookies, `httpOnly`, `secure`, `sameSite=strict`, short TTL |
|
||||
| CSRF | Double-submit cookie pattern (`X-CSRF-Token` header required on all mutations) |
|
||||
| API key leakage via errors | `sanitizeError()` redacts keys/tokens from all error responses and logs |
|
||||
| Token theft after logout | Server-side token store; Emby token revoked on logout |
|
||||
| XSS → token theft | `httpOnly` cookies; CSP with per-request nonce blocks inline injection |
|
||||
| Clickjacking | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` |
|
||||
| Info disclosure via headers | Helmet v7 removes `X-Powered-By`, sets `noSniff`, `xssFilter`, etc. |
|
||||
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
||||
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment Checklist
|
||||
|
||||
### Required
|
||||
|
||||
- [ ] `COOKIE_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
|
||||
- [ ] `NODE_ENV=production`
|
||||
- [ ] `TRUST_PROXY=1` set if behind a reverse proxy
|
||||
- [ ] sofarr bound to `127.0.0.1` only (not `0.0.0.0`) — expose via proxy
|
||||
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
|
||||
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
|
||||
|
||||
### Recommended
|
||||
|
||||
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
|
||||
- [ ] Set `Strict-Transport-Security` at proxy level (sofarr also sends HSTS)
|
||||
- [ ] `DATA_DIR` on a named Docker volume (not bind-mounted to sensitive host path)
|
||||
- [ ] Rotate `COOKIE_SECRET` periodically (causes all users to re-login)
|
||||
- [ ] Enable Docker's `--read-only` flag (already in `docker-compose.yaml`)
|
||||
- [ ] Monitor `/health` endpoint with an uptime checker
|
||||
|
||||
### Docker Secrets (alternative to env vars)
|
||||
|
||||
For production environments that support Docker secrets, you can mount secret
|
||||
files and reference them:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
cookie_secret:
|
||||
file: ./secrets/cookie_secret.txt
|
||||
emby_api_key:
|
||||
file: ./secrets/emby_api_key.txt
|
||||
|
||||
services:
|
||||
sofarr:
|
||||
secrets:
|
||||
- cookie_secret
|
||||
- emby_api_key
|
||||
environment:
|
||||
- COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||
```
|
||||
|
||||
> Since v1.2.0, sofarr natively supports the `_FILE` pattern.
|
||||
> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will
|
||||
> read the secret value from that file at startup. See `docker-compose.yaml`
|
||||
> for a complete example.
|
||||
|
||||
---
|
||||
|
||||
## Reverse Proxy Example (Caddy)
|
||||
|
||||
```caddy
|
||||
sofarr.example.com {
|
||||
reverse_proxy localhost:3001
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reverse Proxy Example (Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sofarr.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sofarr.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sofarr.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Required for SSE (Server-Sent Events) — disable response buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Headers (emitted by sofarr)
|
||||
|
||||
| Header | Value |
|
||||
|--------|-------|
|
||||
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'` |
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` (production only) |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
|
||||
---
|
||||
|
||||
## Supply Chain
|
||||
|
||||
- All dependencies pinned to minor version ranges in `package.json`
|
||||
- `npm audit --audit-level=high` runs in CI on every push and pull request
|
||||
- `npm audit fix` should be run when vulnerabilities are reported
|
||||
@@ -0,0 +1,6 @@
|
||||
# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults.
|
||||
# Never commit real TLS certificates or private keys to version control.
|
||||
*
|
||||
!.gitignore
|
||||
!snakeoil.crt
|
||||
!snakeoil.key
|
||||
@@ -0,0 +1,22 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL
|
||||
BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
|
||||
bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3
|
||||
MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF
|
||||
TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM
|
||||
CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF
|
||||
9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH
|
||||
XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq
|
||||
e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA
|
||||
B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe
|
||||
OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP
|
||||
mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl
|
||||
lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF
|
||||
MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA
|
||||
A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ
|
||||
ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9
|
||||
Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4
|
||||
CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0
|
||||
BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8
|
||||
o59gDmbCXhypom7OsyxBLZgyVWU1
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0
|
||||
hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16
|
||||
iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX
|
||||
7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI
|
||||
SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y
|
||||
gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO
|
||||
MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu
|
||||
oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6
|
||||
VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU
|
||||
fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo
|
||||
gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj
|
||||
j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4
|
||||
psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG
|
||||
dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L
|
||||
2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA
|
||||
erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT
|
||||
Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf
|
||||
JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl
|
||||
jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC
|
||||
xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt
|
||||
YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj
|
||||
DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2
|
||||
k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1
|
||||
jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR
|
||||
/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb
|
||||
OSbN8wfCz53+7qea4NQEB4E=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
container_name: sofarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set)
|
||||
- "3001:3001"
|
||||
# Uncomment the line below and comment out the above to bind to loopback
|
||||
# only when using a reverse proxy (set TLS_ENABLED=false in that case):
|
||||
# - "127.0.0.1:3001:3001"
|
||||
environment:
|
||||
- PORT=3001
|
||||
- NODE_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
# --- TLS ---
|
||||
# Default: TLS enabled using bundled snakeoil cert (self-signed).
|
||||
# Supply your own cert/key by mounting them and setting these paths:
|
||||
# - TLS_CERT=/app/certs/server.crt
|
||||
# - TLS_KEY=/app/certs/server.key
|
||||
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
|
||||
# If using a reverse proxy, also set TRUST_PROXY=1 below.
|
||||
# - TRUST_PROXY=1
|
||||
# --- Secrets: use _FILE variants (Docker secrets) in production -------
|
||||
# Option A — plain environment variables (simple, less secure):
|
||||
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
|
||||
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
|
||||
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
|
||||
# Option B — Docker secrets (_FILE pattern, recommended for production):
|
||||
# Uncomment the lines below and comment out Option A above.
|
||||
# Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt
|
||||
# - COOKIE_SECRET_FILE=/run/secrets/cookie_secret
|
||||
# - EMBY_API_KEY_FILE=/run/secrets/emby_api_key
|
||||
# - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only
|
||||
# - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only
|
||||
# - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only
|
||||
# secrets: # uncomment when using Option B
|
||||
# - cookie_secret
|
||||
# - emby_api_key
|
||||
volumes:
|
||||
# Persistent volume for token store and log file
|
||||
- sofarr-data:/app/data
|
||||
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
|
||||
# - /path/to/your/server.crt:/app/certs/server.crt:ro
|
||||
# - /path/to/your/server.key:/app/certs/server.key:ro
|
||||
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||
user: "1000:1000"
|
||||
# Read-only root filesystem; only the data volume is writable
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp # Node.js needs a writable /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true # prevent privilege escalation via setuid binaries
|
||||
cap_drop:
|
||||
- ALL # drop all Linux capabilities
|
||||
cap_add: [] # add back none — Node.js needs no special caps
|
||||
healthcheck:
|
||||
# Respects TLS_ENABLED: uses http when set to false, https otherwise.
|
||||
# --no-check-certificate handles self-signed / snakeoil certs.
|
||||
test: ["CMD", "/bin/sh", "-c", "[ \"${TLS_ENABLED:-true}\" = \"false\" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
sofarr-data:
|
||||
|
||||
# Docker secrets definitions (uncomment and populate when using Option B above)
|
||||
# secrets:
|
||||
# cookie_secret:
|
||||
# file: ./secrets/cookie_secret.txt
|
||||
# emby_api_key:
|
||||
# file: ./secrets/emby_api_key.txt
|
||||
@@ -0,0 +1,399 @@
|
||||
# Adding a New Download Client to Sofarr
|
||||
|
||||
This guide explains how to add support for a new download client to Sofarr using the Pluggable Download Client Architecture (PDCA).
|
||||
|
||||
## Overview
|
||||
|
||||
The PDCA makes adding new download clients straightforward by providing a standardized interface. You only need to implement the `DownloadClient` abstract base class and register your client in the configuration system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Familiarity with JavaScript/Node.js
|
||||
- Understanding of your target client's API
|
||||
- Basic knowledge of Sofarr's architecture (see [ARCHITECTURE.md](ARCHITECTURE.md))
|
||||
|
||||
## Step 1: Create the Client Class
|
||||
|
||||
Create a new file in `server/clients/` named after your client (e.g., `DelugeClient.js`).
|
||||
|
||||
```javascript
|
||||
// server/clients/DelugeClient.js
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class DelugeClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
// Add any client-specific initialization here
|
||||
this.sessionId = null;
|
||||
this.rpcUrl = `${this.url}/json`;
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'deluge';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
// Implement connection test logic
|
||||
const response = await this.makeRequest('auth.check_session');
|
||||
logToFile(`[Deluge:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(method, params = []) {
|
||||
// Implement RPC call logic
|
||||
const payload = {
|
||||
method: method,
|
||||
params: params,
|
||||
id: Date.now()
|
||||
};
|
||||
|
||||
// Add authentication if needed
|
||||
if (this.sessionId) {
|
||||
payload.params.unshift(this.sessionId);
|
||||
}
|
||||
|
||||
// Make HTTP request to your client's API
|
||||
// Handle authentication, errors, etc.
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Fetch downloads from your client
|
||||
const torrents = await this.makeRequest('core.get_torrents_status',
|
||||
[{}, ['name', 'state', 'progress', 'total_size', 'download_payload_rate']]
|
||||
);
|
||||
|
||||
// Normalize each download using the standard schema
|
||||
return Object.entries(torrents).map(([id, torrent]) =>
|
||||
this.normalizeDownload({ ...torrent, id })
|
||||
);
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Error fetching downloads: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
// Optional: Return client status information
|
||||
const status = await this.makeRequest('core.get_session_status');
|
||||
return status;
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
// Convert client-specific data to the normalized schema
|
||||
return {
|
||||
id: torrent.id,
|
||||
title: torrent.name,
|
||||
type: 'torrent',
|
||||
client: 'deluge',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: this.mapStatus(torrent.state),
|
||||
progress: Math.round(torrent.progress * 100),
|
||||
size: torrent.total_size,
|
||||
downloaded: Math.round(torrent.total_size * torrent.progress),
|
||||
speed: torrent.download_payload_rate,
|
||||
eta: torrent.eta > 0 ? torrent.eta : null,
|
||||
category: torrent.label || undefined,
|
||||
tags: torrent.tracker ? [torrent.tracker] : [],
|
||||
savePath: torrent.save_path,
|
||||
addedOn: torrent.added_time ? new Date(torrent.added_time * 1000).toISOString() : undefined,
|
||||
raw: torrent // Include original data for advanced use cases
|
||||
};
|
||||
}
|
||||
|
||||
mapStatus(state) {
|
||||
// Map client-specific states to normalized statuses
|
||||
const statusMap = {
|
||||
'Downloading': 'Downloading',
|
||||
'Seeding': 'Seeding',
|
||||
'Paused': 'Paused',
|
||||
'Checking': 'Checking',
|
||||
'Error': 'Error',
|
||||
'Queued': 'Queued'
|
||||
};
|
||||
|
||||
return statusMap[state] || state;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DelugeClient;
|
||||
```
|
||||
|
||||
## Step 2: Add Configuration Support
|
||||
|
||||
Update `server/utils/config.js` to add support for your client's environment variables:
|
||||
|
||||
```javascript
|
||||
function getDelugeInstances() {
|
||||
return parseInstances(
|
||||
process.env.DELUGE_INSTANCES,
|
||||
process.env.DELUGE_URL,
|
||||
null, // no apiKey for Deluge
|
||||
process.env.DELUGE_USERNAME,
|
||||
process.env.DELUGE_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
// Add to module.exports
|
||||
module.exports = {
|
||||
// ... existing exports
|
||||
getDelugeInstances,
|
||||
// ... other exports
|
||||
};
|
||||
```
|
||||
|
||||
## Step 3: Register the Client
|
||||
|
||||
Update `server/utils/downloadClients.js` to include your client:
|
||||
|
||||
```javascript
|
||||
const DelugeClient = require('../clients/DelugeClient');
|
||||
|
||||
// Add to clientClasses mapping
|
||||
const clientClasses = {
|
||||
sabnzbd: SABnzbdClient,
|
||||
qbittorrent: QBittorrentClient,
|
||||
transmission: TransmissionClient,
|
||||
deluge: DelugeClient // Add your client here
|
||||
};
|
||||
|
||||
// Update instance configuration
|
||||
const instanceConfigs = [
|
||||
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
|
||||
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
|
||||
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
|
||||
...delugeInstances.map(inst => ({ ...inst, type: 'deluge' })) // Add this line
|
||||
];
|
||||
```
|
||||
|
||||
## Step 4: Update Poller Integration
|
||||
|
||||
The poller automatically uses the registry, so no changes are needed there. However, if you want to maintain backward compatibility with existing cache keys, you may need to update the poller's transformation logic.
|
||||
|
||||
## Step 5: Add Tests
|
||||
|
||||
Create comprehensive tests for your client:
|
||||
|
||||
```javascript
|
||||
// tests/unit/clients/DelugeClient.test.js
|
||||
const DelugeClient = require('../../../server/clients/DelugeClient');
|
||||
|
||||
describe('DelugeClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
id: 'test-deluge',
|
||||
name: 'Test Deluge',
|
||||
url: 'http://localhost:8112',
|
||||
username: 'admin',
|
||||
password: 'deluge'
|
||||
};
|
||||
|
||||
client = new DelugeClient(mockConfig);
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('deluge');
|
||||
expect(client.getInstanceId()).toBe('test-deluge');
|
||||
expect(client.name).toBe('Test Deluge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
// Mock successful connection
|
||||
client.makeRequest = jest.fn().mockResolvedValue({ result: true });
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('auth.check_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Normalization', () => {
|
||||
it('should normalize download data correctly', () => {
|
||||
const torrent = {
|
||||
id: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
state: 'Downloading',
|
||||
progress: 0.75,
|
||||
total_size: 1000000000,
|
||||
download_payload_rate: 1048576,
|
||||
eta: 3600,
|
||||
label: 'movies',
|
||||
save_path: '/downloads/test'
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'abc123',
|
||||
title: 'Test Torrent',
|
||||
type: 'torrent',
|
||||
client: 'deluge',
|
||||
instanceId: 'test-deluge',
|
||||
instanceName: 'Test Deluge',
|
||||
status: 'Downloading',
|
||||
progress: 75,
|
||||
size: 1000000000,
|
||||
downloaded: 750000000,
|
||||
speed: 1048576,
|
||||
eta: 3600,
|
||||
category: 'movies',
|
||||
tags: [],
|
||||
savePath: '/downloads/test',
|
||||
raw: torrent
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add more tests for error handling, edge cases, etc.
|
||||
});
|
||||
```
|
||||
|
||||
## Step 6: Configuration Examples
|
||||
|
||||
Add documentation for your client's configuration in `.env.sample`:
|
||||
|
||||
```bash
|
||||
# Deluge Configuration
|
||||
# Single instance (legacy format)
|
||||
# DELUGE_URL=http://localhost:8112
|
||||
# DELUGE_USERNAME=admin
|
||||
# DELUGE_PASSWORD=deluge
|
||||
|
||||
# Multiple instances (JSON format)
|
||||
DELUGE_INSTANCES='[
|
||||
{
|
||||
"name": "Main Deluge",
|
||||
"url": "http://localhost:8112",
|
||||
"username": "admin",
|
||||
"password": "deluge"
|
||||
},
|
||||
{
|
||||
"name": "Backup Deluge",
|
||||
"url": "http://localhost:8113",
|
||||
"username": "admin",
|
||||
"password": "deluge"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
## Step 7: Update Documentation
|
||||
|
||||
Update relevant documentation files:
|
||||
|
||||
1. **ARCHITECTURE.md**: Add your client to the download clients section
|
||||
2. **README.md**: Add configuration instructions for your client
|
||||
3. **CHANGELOG.md**: Document the new client support
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Always wrap API calls in try-catch blocks
|
||||
- Return empty arrays for download fetch failures
|
||||
- Log errors with appropriate context
|
||||
- Implement retry logic where appropriate
|
||||
|
||||
### Authentication
|
||||
|
||||
- Store credentials securely (don't log them)
|
||||
- Handle session expiration gracefully
|
||||
- Implement automatic re-authentication when possible
|
||||
|
||||
### Performance
|
||||
|
||||
- Use efficient API calls (batch requests when available)
|
||||
- Implement caching for expensive operations
|
||||
- Consider pagination for large download lists
|
||||
- Use connection pooling for HTTP clients
|
||||
|
||||
### Normalization
|
||||
|
||||
- Always return the complete normalized schema
|
||||
- Handle missing or null values gracefully
|
||||
- Preserve original data in the `raw` field
|
||||
- Map client-specific statuses to standard ones
|
||||
|
||||
### Testing
|
||||
|
||||
- Test both success and failure scenarios
|
||||
- Mock external API calls
|
||||
- Test normalization edge cases
|
||||
- Include integration tests
|
||||
|
||||
## Example: Complete Implementation
|
||||
|
||||
For a complete example, refer to the existing client implementations:
|
||||
|
||||
- **SABnzbdClient.js**: Simple REST API client
|
||||
- **QBittorrentClient.js**: Complex client with sync API and fallback
|
||||
- **TransmissionClient.js**: JSON-RPC client with session management
|
||||
- **RTorrentClient.js**: XML-RPC client with HTTP Basic Auth
|
||||
|
||||
### rTorrent Specific Notes
|
||||
|
||||
rTorrent uses XML-RPC over HTTP with the following specifics:
|
||||
|
||||
- **Endpoint**: `${url}/RPC2` (most common)
|
||||
- **Authentication**: HTTP Basic Auth (handled by reverse proxy or web server)
|
||||
- **Primary Method**: `d.multicall2` for efficient bulk torrent data retrieval
|
||||
- **Library**: Uses the `xmlrpc` package (v1.3.2)
|
||||
- **Status Mapping**: Combines `d.state`, `d.is_active`, and `d.is_hash_checking` to determine status
|
||||
- **ETA Calculation**: Computed from download speed and remaining bytes when actively downloading
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication failures**: Check credentials and URL format
|
||||
2. **API changes**: Ensure your client matches the API version
|
||||
3. **Network issues**: Implement proper timeout and retry logic
|
||||
4. **Data normalization**: Verify all required fields are populated
|
||||
|
||||
### Debugging
|
||||
|
||||
- Enable debug logging in your client
|
||||
- Check the server logs for error messages
|
||||
- Use the test connection endpoint to verify configuration
|
||||
- Test API calls manually before implementing
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing a new client:
|
||||
|
||||
1. Follow the existing code style and patterns
|
||||
2. Include comprehensive tests
|
||||
3. Update all relevant documentation
|
||||
4. Test with multiple instances if supported
|
||||
5. Consider edge cases and error scenarios
|
||||
|
||||
## Support
|
||||
|
||||
If you need help implementing a new client:
|
||||
|
||||
1. Review existing client implementations
|
||||
2. Check the architecture documentation
|
||||
3. Look at the test examples
|
||||
4. Ask questions in the project discussions
|
||||
|
||||
---
|
||||
|
||||
*This guide covers the basics of adding a new download client. For more advanced scenarios, refer to the source code and existing implementations.*
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2515
-1297
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -1,24 +1,37 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.1.0",
|
||||
"version": "1.4.0",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"start": "node server/index.js",
|
||||
"install:all": "npm install"
|
||||
"install:all": "npm install",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"axios": "^1.6.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"cookie-parser": "^1.4.6"
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"xmlrpc": "^1.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22",
|
||||
"concurrently": "^7.6.0"
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"concurrently": "^7.6.0",
|
||||
"nock": "^14.0.15",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"keywords": [
|
||||
"sabnzbd",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Contact: mailto:gordon@i3omb.com
|
||||
Expires: 2026-12-31T23:59:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://git.i3omb.com/Gandalf/sofarr
|
||||
Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md
|
||||
+729
-60
@@ -1,52 +1,224 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// History section state
|
||||
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
|
||||
let historyRefreshHandle = null;
|
||||
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
||||
let ignoreAvailable = localStorage.getItem('sofarr-ignore-available') === 'true';
|
||||
let lastHistoryItems = []; // raw items from last fetch, for re-filtering without a network round-trip
|
||||
|
||||
// SSE stream state
|
||||
let sseSource = null;
|
||||
let sseReconnectTimer = null;
|
||||
const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
(function() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
})();
|
||||
|
||||
// Check authentication on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthentication();
|
||||
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initHistoryControls();
|
||||
loadAppVersion();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
document.getElementById('title-home-link').addEventListener('click', e => { e.preventDefault(); goHome(); });
|
||||
});
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (currentRefreshRate > 0) {
|
||||
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
|
||||
function loadAppVersion() {
|
||||
fetch('/health')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.version) {
|
||||
document.getElementById('app-version').textContent = `sofarr v${data.version}`;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function initThemeSwitcher() {
|
||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === saved);
|
||||
btn.addEventListener('click', () => setTheme(btn.dataset.theme));
|
||||
});
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('sofarr-theme', theme);
|
||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||
});
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
closeStatusPanel();
|
||||
// Reset showAll if active
|
||||
if (showAll) {
|
||||
showAll = false;
|
||||
const toggle = document.getElementById('show-all-toggle');
|
||||
if (toggle) toggle.checked = false;
|
||||
startSSE();
|
||||
}
|
||||
activateTab('downloads', true);
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
|
||||
activateTab(savedTab, false);
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.dataset.tab;
|
||||
activateTab(tab, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function activateTab(tabName, save) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none';
|
||||
});
|
||||
if (save) localStorage.setItem('sofarr-active-tab', tabName);
|
||||
// Load history the first time the history tab is shown
|
||||
if (tabName === 'history') loadHistory();
|
||||
}
|
||||
|
||||
// --- SSE connection management ---
|
||||
|
||||
function startSSE() {
|
||||
stopSSE();
|
||||
const params = showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.isAdmin;
|
||||
downloads = data.downloads;
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
function stopSSE() {
|
||||
if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; }
|
||||
if (sseSource) {
|
||||
sseSource.close();
|
||||
sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
function handleShowAllToggle(e) {
|
||||
showAll = e.target.checked;
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
// Reload history with updated showAll param
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
function fadeOutLogin() {
|
||||
return new Promise(resolve => {
|
||||
const login = document.getElementById('login-container');
|
||||
login.classList.add('fade-out');
|
||||
login.addEventListener('transitionend', () => {
|
||||
login.style.display = 'none';
|
||||
login.classList.remove('fade-out');
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function showSplash() {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.style.display = 'flex';
|
||||
splash.style.opacity = '1';
|
||||
splash.classList.remove('fade-out');
|
||||
}
|
||||
|
||||
function dismissSplash(startTime) {
|
||||
return new Promise(resolve => {
|
||||
const elapsed = Date.now() - (startTime || 0);
|
||||
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.classList.add('fade-out');
|
||||
// Fallback: resolve after transition duration + buffer in case
|
||||
// transitionend never fires (e.g. display was toggled in same frame)
|
||||
const TRANSITION_MS = 400;
|
||||
const fallback = setTimeout(() => {
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, TRANSITION_MS + 100);
|
||||
splash.addEventListener('transitionend', () => {
|
||||
clearTimeout(fallback);
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, { once: true });
|
||||
}, remaining);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkAuthentication() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
@@ -56,6 +228,7 @@ async function handleLogin(e) {
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const rememberMe = document.getElementById('remember-me').checked;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
@@ -63,16 +236,28 @@ async function handleLogin(e) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, rememberMe })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) csrfToken = data.csrfToken;
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
await fadeOutLogin();
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
}
|
||||
@@ -84,12 +269,17 @@ async function handleLogin(e) {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
stopAutoRefresh();
|
||||
stopSSE();
|
||||
stopHistoryRefresh();
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
});
|
||||
currentUser = null;
|
||||
csrfToken = null;
|
||||
downloads = [];
|
||||
clearHistory();
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
@@ -106,6 +296,15 @@ function showDashboard() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('dashboard-container').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = currentUser.name || '-';
|
||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||
const sp = document.getElementById('status-panel');
|
||||
sp.style.display = 'none';
|
||||
sp.innerHTML = '';
|
||||
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||
// Initialise days input from saved value
|
||||
const daysInput = document.getElementById('history-days');
|
||||
if (daysInput) daysInput.value = historyDays;
|
||||
startHistoryRefresh();
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
@@ -119,36 +318,33 @@ function hideLoginError() {
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
async function fetchUserDownloads(isInitialLoad = false) {
|
||||
if (isInitialLoad) {
|
||||
showLoading();
|
||||
}
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/user-downloads');
|
||||
const data = await response.json();
|
||||
|
||||
currentUser = data.user;
|
||||
downloads = data.downloads;
|
||||
|
||||
// Debug: log first download to see what fields are present
|
||||
if (downloads.length > 0) {
|
||||
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
|
||||
}
|
||||
|
||||
document.getElementById('currentUser').textContent = currentUser || '-';
|
||||
renderDownloads();
|
||||
} catch (err) {
|
||||
showError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
hideLoading();
|
||||
}
|
||||
// Build an episode-info element for series downloads/history.
|
||||
// Single episode: "S01E05 — Episode Title"
|
||||
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
||||
// Returns null if no episode data.
|
||||
function formatEpisodeInfo(episodes) {
|
||||
if (!episodes || episodes.length === 0) return null;
|
||||
const el = document.createElement('p');
|
||||
el.className = 'episode-info';
|
||||
if (episodes.length === 1) {
|
||||
const ep = episodes[0];
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
} else {
|
||||
el.textContent = 'Multiple episodes';
|
||||
el.classList.add('multi-episode');
|
||||
const lines = episodes.map(ep => {
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
});
|
||||
el.setAttribute('data-tooltip', lines.join('\n'));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||
|
||||
function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
@@ -254,13 +450,57 @@ function updateDownloadCard(card, download) {
|
||||
peersEl.textContent = download.peers;
|
||||
}
|
||||
|
||||
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
||||
if (availabilityEl && download.availability !== undefined) {
|
||||
availabilityEl.textContent = `${download.availability}%`;
|
||||
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||
if (availabilityItem && download.availability !== undefined) {
|
||||
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlocklistSearch(btn, download) {
|
||||
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Working…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
arrQueueId: download.arrQueueId,
|
||||
arrType: download.arrType,
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
btn.textContent = '✓ Done — searching…';
|
||||
btn.className = 'blocklist-search-btn success';
|
||||
} catch (err) {
|
||||
console.error('[Blocklist] Error:', err);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '⛔ Blocklist & Search';
|
||||
btn.className = 'blocklist-search-btn error';
|
||||
btn.title = `Failed: ${err.message}`;
|
||||
setTimeout(() => {
|
||||
btn.className = 'blocklist-search-btn';
|
||||
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadCard(download) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `download-card ${download.type}`;
|
||||
@@ -271,7 +511,11 @@ function createDownloadCard(download) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'download-cover';
|
||||
const coverImg = document.createElement('img');
|
||||
coverImg.src = download.coverArt;
|
||||
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||
// is satisfied (external poster URLs would be blocked otherwise).
|
||||
coverImg.src = download.coverArt
|
||||
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||
: '';
|
||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||
coverImg.loading = 'lazy';
|
||||
coverDiv.appendChild(coverImg);
|
||||
@@ -304,6 +548,23 @@ function createDownloadCard(download) {
|
||||
|
||||
header.appendChild(type);
|
||||
header.appendChild(status);
|
||||
|
||||
if (download.importIssues && download.importIssues.length > 0) {
|
||||
const issueBadge = document.createElement('span');
|
||||
issueBadge.className = 'import-issue-badge';
|
||||
issueBadge.textContent = 'Import Pending';
|
||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||
header.appendChild(issueBadge);
|
||||
}
|
||||
|
||||
if ((isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||
const blBtn = document.createElement('button');
|
||||
blBtn.className = 'blocklist-search-btn';
|
||||
blBtn.textContent = '⛔ Blocklist & Search';
|
||||
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'download-title';
|
||||
@@ -315,16 +576,52 @@ function createDownloadCard(download) {
|
||||
if (download.seriesName) {
|
||||
const series = document.createElement('p');
|
||||
series.className = 'download-series';
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
if (isAdmin && download.arrLink) {
|
||||
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
||||
} else {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
}
|
||||
infoDiv.appendChild(series);
|
||||
const epEl = formatEpisodeInfo(download.episodes);
|
||||
if (epEl) infoDiv.appendChild(epEl);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
if (isAdmin && download.arrLink) {
|
||||
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
||||
} else {
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
}
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
|
||||
// In showAll mode: render all tags classified by whether they match an Emby user.
|
||||
// Unmatched (no known Emby user) → amber, leftmost.
|
||||
// Matched → show Emby display name in accent colour, rightmost.
|
||||
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = download.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (download.matchedUserTag) {
|
||||
// Normal (non-showAll) view: show only the current user's matched tag
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = download.matchedUserTag;
|
||||
header.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'download-details';
|
||||
@@ -407,6 +704,7 @@ function createDownloadCard(download) {
|
||||
|
||||
if (download.availability !== undefined) {
|
||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
@@ -415,6 +713,24 @@ function createDownloadCard(download) {
|
||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||
details.appendChild(completed);
|
||||
}
|
||||
|
||||
if (isAdmin && (download.downloadPath || download.targetPath)) {
|
||||
const pathsDiv = document.createElement('div');
|
||||
pathsDiv.className = 'download-paths';
|
||||
if (download.downloadPath) {
|
||||
const dlPath = document.createElement('div');
|
||||
dlPath.className = 'path-item';
|
||||
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||
pathsDiv.appendChild(dlPath);
|
||||
}
|
||||
if (download.targetPath) {
|
||||
const tgtPath = document.createElement('div');
|
||||
tgtPath.className = 'path-item';
|
||||
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||
pathsDiv.appendChild(tgtPath);
|
||||
}
|
||||
details.appendChild(pathsDiv);
|
||||
}
|
||||
|
||||
infoDiv.appendChild(details);
|
||||
card.appendChild(infoDiv);
|
||||
@@ -441,6 +757,141 @@ function createDetailItem(label, value) {
|
||||
return item;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
let statusRefreshHandle = null;
|
||||
const STATUS_REFRESH_MS = 5000;
|
||||
|
||||
async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
return;
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
await refreshStatusPanel();
|
||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
function closeStatusPanel() {
|
||||
document.getElementById('status-panel').style.display = 'none';
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
}
|
||||
|
||||
async function refreshStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch status');
|
||||
const data = await res.json();
|
||||
renderStatusPanel(data, panel);
|
||||
} catch (err) {
|
||||
// Don't overwrite panel on transient error during auto-refresh
|
||||
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
|
||||
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatusPanel(data, panel) {
|
||||
const s = data.server;
|
||||
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
||||
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
||||
const secs = s.uptimeSeconds % 60;
|
||||
const uptime = `${hrs}h ${mins}m ${secs}s`;
|
||||
|
||||
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
|
||||
|
||||
let html = `
|
||||
<div class="status-header">
|
||||
<h3>Server Status</h3>
|
||||
<button class="status-close" onclick="closeStatusPanel()">×</button>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Server</div>
|
||||
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
||||
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
||||
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
||||
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Data Refresh</div>`;
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Poll timings card
|
||||
const lp = data.polling.lastPoll;
|
||||
if (lp) {
|
||||
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Cache table
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
||||
<table class="status-table">
|
||||
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const e of data.cache.entries) {
|
||||
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
|
||||
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
||||
const items = e.itemCount !== null ? e.itemCount : '—';
|
||||
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
||||
}
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
panel.innerHTML = html;
|
||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||
el.style.width = el.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (!size) return 'N/A';
|
||||
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
||||
@@ -478,3 +929,221 @@ function hideLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// History section
|
||||
// =============================================================================
|
||||
|
||||
function initHistoryControls() {
|
||||
const daysInput = document.getElementById('history-days');
|
||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||
const ignoreToggle = document.getElementById('ignore-available-toggle');
|
||||
if (daysInput) {
|
||||
daysInput.addEventListener('change', () => {
|
||||
const v = parseInt(daysInput.value, 10);
|
||||
if (v > 0 && v <= 90) {
|
||||
historyDays = v;
|
||||
localStorage.setItem('sofarr-history-days', v);
|
||||
loadHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||
}
|
||||
if (ignoreToggle) {
|
||||
ignoreToggle.checked = ignoreAvailable;
|
||||
ignoreToggle.addEventListener('change', () => {
|
||||
ignoreAvailable = ignoreToggle.checked;
|
||||
localStorage.setItem('sofarr-ignore-available', ignoreAvailable);
|
||||
renderHistory(lastHistoryItems);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startHistoryRefresh() {
|
||||
stopHistoryRefresh();
|
||||
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||
}
|
||||
|
||||
function stopHistoryRefresh() {
|
||||
if (historyRefreshHandle) {
|
||||
clearInterval(historyRefreshHandle);
|
||||
historyRefreshHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
lastHistoryItems = [];
|
||||
document.getElementById('history-list').innerHTML = '';
|
||||
document.getElementById('no-history').style.display = 'none';
|
||||
document.getElementById('history-error').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadHistory(forceRefresh = false) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const loadingEl = document.getElementById('history-loading');
|
||||
const errorEl = document.getElementById('history-error');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
|
||||
loadingEl.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
noHistoryEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ days: historyDays });
|
||||
if (showAll) params.set('showAll', 'true');
|
||||
if (forceRefresh) params.set('_t', Date.now());
|
||||
const res = await fetch(`/api/history/recent?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
loadingEl.style.display = 'none';
|
||||
lastHistoryItems = data.history || [];
|
||||
renderHistory(lastHistoryItems);
|
||||
} catch (err) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.textContent = 'Failed to load history.';
|
||||
errorEl.style.display = 'block';
|
||||
console.error('[History] Load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(items) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
listEl.innerHTML = '';
|
||||
const visible = ignoreAvailable
|
||||
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
|
||||
: items;
|
||||
if (!visible.length) {
|
||||
noHistoryEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
noHistoryEl.style.display = 'none';
|
||||
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||
}
|
||||
|
||||
function createHistoryCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `history-card ${item.type} ${item.outcome}`;
|
||||
|
||||
if (item.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'history-cover';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
||||
img.alt = item.movieName || item.seriesName || item.title;
|
||||
img.loading = 'lazy';
|
||||
coverDiv.appendChild(img);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'history-info';
|
||||
|
||||
// Header row: type badge + outcome badge
|
||||
const header = document.createElement('div');
|
||||
header.className = 'history-card-header';
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = `history-type-badge ${item.type}`;
|
||||
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
||||
header.appendChild(typeBadge);
|
||||
|
||||
const outcomeBadge = document.createElement('span');
|
||||
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
||||
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||
header.appendChild(outcomeBadge);
|
||||
|
||||
if (item.availableForUpgrade) {
|
||||
const upgradeBadge = document.createElement('span');
|
||||
upgradeBadge.className = 'history-upgrade-badge';
|
||||
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
|
||||
upgradeBadge.textContent = '⬆ Available';
|
||||
header.appendChild(upgradeBadge);
|
||||
}
|
||||
|
||||
if (item.instanceName) {
|
||||
const instBadge = document.createElement('span');
|
||||
instBadge.className = 'history-instance-badge';
|
||||
instBadge.textContent = item.instanceName;
|
||||
header.appendChild(instBadge);
|
||||
}
|
||||
|
||||
if (showAll && item.tagBadges && item.tagBadges.length > 0) {
|
||||
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = item.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (item.matchedUserTag) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = item.matchedUserTag;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
|
||||
info.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'history-title';
|
||||
title.textContent = item.title;
|
||||
info.appendChild(title);
|
||||
|
||||
// Series/movie name with optional arr link
|
||||
if (item.seriesName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
}
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Movie: ' + item.movieName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
}
|
||||
|
||||
// Detail pills
|
||||
const details = document.createElement('div');
|
||||
details.className = 'history-details';
|
||||
|
||||
if (item.completedAt) {
|
||||
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
||||
}
|
||||
if (item.quality) {
|
||||
details.appendChild(createDetailItem('Quality', item.quality));
|
||||
}
|
||||
|
||||
// Failed imports: show failure message
|
||||
if (item.outcome === 'failed' && item.failureMessage) {
|
||||
const failItem = document.createElement('div');
|
||||
failItem.className = 'history-failure-message';
|
||||
failItem.textContent = item.failureMessage;
|
||||
details.appendChild(failItem);
|
||||
}
|
||||
|
||||
info.appendChild(details);
|
||||
card.appendChild(info);
|
||||
return card;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+69
-16
@@ -4,14 +4,24 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sofarr - Your Downloads Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash Screen -->
|
||||
<div id="splash-screen" class="splash-screen">
|
||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
<!-- Login Form -->
|
||||
<div id="login-container" class="login-container" style="display: none;">
|
||||
<div class="login-box">
|
||||
<h2>Login to Emby</h2>
|
||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
||||
<p class="login-subtitle">Login with your Emby credentials</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
@@ -21,6 +31,12 @@
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="remember-me" name="rememberMe">
|
||||
<span>Keep me logged in</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
@@ -30,16 +46,19 @@
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<header class="app-header">
|
||||
<h1>sofarr</h1>
|
||||
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||
<div class="header-controls">
|
||||
<div class="refresh-control">
|
||||
<label for="refresh-rate">Refresh:</label>
|
||||
<select id="refresh-rate">
|
||||
<option value="1000">1s</option>
|
||||
<option value="5000" selected>5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="0">Off</option>
|
||||
</select>
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-btn active" data-theme="light">Light</button>
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
<span>Show all users</span>
|
||||
</label>
|
||||
<button id="status-btn" class="status-btn">Status</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-label">Current User:</span>
|
||||
@@ -49,21 +68,55 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
||||
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||
|
||||
<div class="downloads-container">
|
||||
<h2>Your Downloads</h2>
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
<div class="main-tabs">
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-downloads">
|
||||
<div class="downloads-container">
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||
<div class="history-container" id="history-container">
|
||||
<div class="history-header">
|
||||
<div class="history-controls">
|
||||
<label class="history-days-label" for="history-days">Last</label>
|
||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||
<span class="history-days-label">days</span>
|
||||
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
||||
<input type="checkbox" id="ignore-available-toggle">
|
||||
<span>Hide upgrade failures</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||
<div id="no-history" class="no-history" style="display: none;">
|
||||
<p>No completed downloads found in this period.</p>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-list"></div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1307
-201
File diff suppressed because it is too large
Load Diff
+117
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Express application factory — imported by both server/index.js (production)
|
||||
* and the test suite. Keeping app creation separate from app.listen() means
|
||||
* tests can import a fresh instance without starting a real server or
|
||||
* triggering the side-effects in index.js (log files, process.exit, poller).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
const app = express();
|
||||
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
|
||||
// Per-request CSP nonce
|
||||
app.use((req, res, next) => {
|
||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrcAttr: ["'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginEmbedderPolicy: false
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
||||
next();
|
||||
});
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET || undefined));
|
||||
app.use(express.json({ limit: '64kb' }));
|
||||
|
||||
// Health / readiness (no auth, no rate-limit)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
res.json({ status: 'ready' });
|
||||
} else {
|
||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// Global error handler
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server] Unhandled error:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* Abstract base class for all download clients.
|
||||
* Defines the common interface that all download clients must implement.
|
||||
*/
|
||||
class DownloadClient {
|
||||
/**
|
||||
* @param {Object} instanceConfig - Configuration for this client instance
|
||||
* @param {string} instanceConfig.id - Unique identifier for this instance
|
||||
* @param {string} instanceConfig.name - Display name for this instance
|
||||
* @param {string} instanceConfig.url - Base URL for the client API
|
||||
* @param {string} [instanceConfig.apiKey] - API key for authentication (if applicable)
|
||||
* @param {string} [instanceConfig.username] - Username for authentication (if applicable)
|
||||
* @param {string} [instanceConfig.password] - Password for authentication (if applicable)
|
||||
*/
|
||||
constructor(instanceConfig) {
|
||||
if (this.constructor === DownloadClient) {
|
||||
throw new Error('DownloadClient is an abstract class and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.id = instanceConfig.id;
|
||||
this.name = instanceConfig.name;
|
||||
this.url = instanceConfig.url;
|
||||
this.apiKey = instanceConfig.apiKey;
|
||||
this.username = instanceConfig.username;
|
||||
this.password = instanceConfig.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client type identifier (e.g., 'qbittorrent', 'sabnzbd', 'transmission')
|
||||
* @returns {string} The client type
|
||||
*/
|
||||
getClientType() {
|
||||
throw new Error('getClientType() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique instance ID
|
||||
* @returns {string} The instance ID
|
||||
*/
|
||||
getInstanceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to the download client
|
||||
* @returns {Promise<boolean>} True if connection is successful
|
||||
*/
|
||||
async testConnection() {
|
||||
throw new Error('testConnection() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active downloads from this client
|
||||
* @returns {Promise<Array<NormalizedDownload>>} Array of normalized download objects
|
||||
*/
|
||||
async getActiveDownloads() {
|
||||
throw new Error('getActiveDownloads() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Get client status information
|
||||
* @returns {Promise<Object|null>} Client status object or null if not supported
|
||||
*/
|
||||
async getClientStatus() {
|
||||
return null; // Default implementation - optional method
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a download object to the standard schema
|
||||
* @param {Object} download - Raw download object from client
|
||||
* @returns {NormalizedDownload} Normalized download object
|
||||
*/
|
||||
normalizeDownload(download) {
|
||||
throw new Error('normalizeDownload() must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedDownload
|
||||
* @property {string} id - Client-specific unique ID
|
||||
* @property {string} title - Download title/name
|
||||
* @property {'usenet'|'torrent'} type - Download type
|
||||
* @property {string} client - Client identifier ('sabnzbd', 'qbittorrent', 'transmission', etc.)
|
||||
* @property {string} instanceId - Instance identifier
|
||||
* @property {string} instanceName - Instance display name
|
||||
* @property {string} status - Normalized status (Downloading, Seeding, Paused, etc.)
|
||||
* @property {number} progress - Progress percentage (0-100)
|
||||
* @property {number} size - Total size in bytes
|
||||
* @property {number} downloaded - Downloaded bytes
|
||||
* @property {number} speed - Current speed in bytes/sec
|
||||
* @property {number|null} eta - Estimated time remaining in seconds, null if unknown
|
||||
* @property {string|undefined} category - Download category (optional)
|
||||
* @property {string[]|undefined} tags - Download tags (optional)
|
||||
* @property {string|undefined} savePath - Save path (optional)
|
||||
* @property {string|undefined} addedOn - Added timestamp (optional)
|
||||
* @property {number|undefined} arrQueueId - Sonarr/Radarr queue ID (optional)
|
||||
* @property {'series'|'movie'|undefined} arrType - Sonarr/Radarr type (optional)
|
||||
* @property {any|undefined} raw - Original client response (escape hatch)
|
||||
*/
|
||||
|
||||
module.exports = DownloadClient;
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class QBittorrentClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
this.authCookie = null;
|
||||
// Sync API incremental state
|
||||
this.lastRid = 0;
|
||||
this.torrentMap = new Map();
|
||||
this.fallbackThisCycle = false;
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'qbittorrent';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.login();
|
||||
// Try a simple API call to verify connection
|
||||
await this.makeRequest('/api/v2/app/version');
|
||||
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
try {
|
||||
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
|
||||
const response = await axios.post(`${this.url}/api/v2/auth/login`,
|
||||
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
}
|
||||
);
|
||||
|
||||
if (response.headers['set-cookie']) {
|
||||
this.authCookie = response.headers['set-cookie'][0];
|
||||
logToFile(`[qBittorrent:${this.name}] Login successful`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(endpoint, config = {}) {
|
||||
const url = `${this.url}${endpoint}`;
|
||||
|
||||
if (!this.authCookie) {
|
||||
const loggedIn = await this.login();
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to authenticate with ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// If unauthorized, try re-authenticating once
|
||||
if (error.response && error.response.status === 403) {
|
||||
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
|
||||
this.authCookie = null;
|
||||
const loggedIn = await this.login();
|
||||
if (loggedIn) {
|
||||
return axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches incremental torrent data using the qBittorrent Sync API.
|
||||
*/
|
||||
async getMainData() {
|
||||
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.full_update) {
|
||||
// Full refresh: rebuild the entire map
|
||||
this.torrentMap.clear();
|
||||
if (data.torrents) {
|
||||
for (const [hash, props] of Object.entries(data.torrents)) {
|
||||
this.torrentMap.set(hash, { ...props, hash });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Delta update: merge changed fields into existing torrent objects
|
||||
if (data.torrents) {
|
||||
for (const [hash, delta] of Object.entries(data.torrents)) {
|
||||
const existing = this.torrentMap.get(hash) || { hash };
|
||||
this.torrentMap.set(hash, { ...existing, ...delta });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove torrents that the server reports as deleted
|
||||
if (data.torrents_removed) {
|
||||
for (const hash of data.torrents_removed) {
|
||||
this.torrentMap.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure every torrent has a computed 'completed' field for downstream consumers
|
||||
for (const torrent of this.torrentMap.values()) {
|
||||
if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) {
|
||||
torrent.completed = Math.round(torrent.size * torrent.progress);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastRid = data.rid;
|
||||
return Array.from(this.torrentMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy full-list fetch. Used as a fallback when the Sync API fails.
|
||||
*/
|
||||
async getTorrentsLegacy() {
|
||||
try {
|
||||
const response = await this.makeRequest('/api/v2/torrents/info');
|
||||
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
if (this.fallbackThisCycle) {
|
||||
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
|
||||
const torrents = await this.getTorrentsLegacy();
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
}
|
||||
|
||||
const torrents = await this.getMainData();
|
||||
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
|
||||
this.fallbackThisCycle = true;
|
||||
try {
|
||||
const torrents = await this.getTorrentsLegacy();
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (fallbackError) {
|
||||
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const response = await this.makeRequest('/api/v2/sync/maindata');
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
serverState: data.server_state || {},
|
||||
rid: data.rid,
|
||||
fullUpdate: data.full_update
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
const totalSize = torrent.size;
|
||||
const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress);
|
||||
const progress = torrent.progress * 100;
|
||||
|
||||
// Map qBittorrent states to our normalized status
|
||||
const stateMap = {
|
||||
'downloading': 'Downloading',
|
||||
'stalledDL': 'Downloading',
|
||||
'metaDL': 'Downloading',
|
||||
'forcedDL': 'Downloading',
|
||||
'allocating': 'Downloading',
|
||||
'uploading': 'Seeding',
|
||||
'stalledUP': 'Seeding',
|
||||
'forcedUP': 'Seeding',
|
||||
'queuedUP': 'Queued',
|
||||
'queuedDL': 'Queued',
|
||||
'checkingUP': 'Checking',
|
||||
'checkingDL': 'Checking',
|
||||
'checkingResumeData': 'Checking',
|
||||
'moving': 'Moving',
|
||||
'pausedUP': 'Paused',
|
||||
'pausedDL': 'Paused',
|
||||
'stoppedUP': 'Stopped',
|
||||
'stoppedDL': 'Stopped',
|
||||
'error': 'Error',
|
||||
'missingFiles': 'Error',
|
||||
'unknown': 'Unknown'
|
||||
};
|
||||
|
||||
const status = stateMap[torrent.state] || torrent.state;
|
||||
|
||||
return {
|
||||
id: torrent.hash,
|
||||
title: torrent.name,
|
||||
type: 'torrent',
|
||||
client: 'qbittorrent',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: status,
|
||||
progress: Math.round(progress),
|
||||
size: totalSize,
|
||||
downloaded: downloadedSize,
|
||||
speed: torrent.dlspeed,
|
||||
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
||||
category: torrent.category || undefined,
|
||||
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
||||
savePath: torrent.content_path || torrent.save_path || undefined,
|
||||
addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined,
|
||||
raw: torrent
|
||||
};
|
||||
}
|
||||
|
||||
// Reset fallback flag (called by registry at start of each poll cycle)
|
||||
resetFallbackFlag() {
|
||||
this.fallbackThisCycle = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QBittorrentClient;
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const xmlrpc = require('xmlrpc');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* rTorrent download client implementation.
|
||||
* Communicates via XML-RPC over HTTP.
|
||||
* Supports HTTP Basic Auth when username/password are configured.
|
||||
* The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc).
|
||||
*/
|
||||
class RTorrentClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
this._createClient();
|
||||
}
|
||||
|
||||
_createClient() {
|
||||
const clientOptions = { url: this.url };
|
||||
|
||||
if (this.username && this.password) {
|
||||
clientOptions.headers = {
|
||||
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`
|
||||
};
|
||||
}
|
||||
|
||||
this.client = xmlrpc.createClient(clientOptions);
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'rtorrent';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
await this._methodCall('system.client_version');
|
||||
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap xmlrpc methodCall in a Promise.
|
||||
* @param {string} method - XML-RPC method name
|
||||
* @param {Array} params - Method parameters
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
_methodCall(method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.methodCall(method, params, (error, value) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
const torrents = await this._methodCall('d.multicall2', [
|
||||
'',
|
||||
'd.hash=',
|
||||
'd.name=',
|
||||
'd.size_bytes=',
|
||||
'd.completed_bytes=',
|
||||
'd.down.rate=',
|
||||
'd.up.rate=',
|
||||
'd.state=',
|
||||
'd.is_active=',
|
||||
'd.is_hash_checking=',
|
||||
'd.directory=',
|
||||
'd.custom1='
|
||||
]);
|
||||
|
||||
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const [downRate, upRate] = await Promise.all([
|
||||
this._methodCall('throttle.global_down.rate'),
|
||||
this._methodCall('throttle.global_up.rate')
|
||||
]);
|
||||
|
||||
return {
|
||||
globalDownRate: downRate,
|
||||
globalUpRate: upRate
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
const [
|
||||
hash,
|
||||
name,
|
||||
sizeBytes,
|
||||
completedBytes,
|
||||
downRate,
|
||||
upRate,
|
||||
state,
|
||||
isActive,
|
||||
isHashChecking,
|
||||
directory,
|
||||
custom1
|
||||
] = torrent;
|
||||
|
||||
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
||||
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
||||
|
||||
// Calculate ETA when actively downloading
|
||||
let eta = null;
|
||||
if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) {
|
||||
eta = Math.round((sizeBytes - completedBytes) / downRate);
|
||||
}
|
||||
|
||||
const arrInfo = this._extractArrInfo(name);
|
||||
|
||||
return {
|
||||
id: hash,
|
||||
title: name,
|
||||
type: 'torrent',
|
||||
client: 'rtorrent',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status,
|
||||
progress,
|
||||
size: sizeBytes,
|
||||
downloaded: completedBytes,
|
||||
speed: status === 'Seeding' ? upRate : downRate,
|
||||
eta,
|
||||
category: custom1 || undefined,
|
||||
tags: custom1 ? [custom1] : [],
|
||||
savePath: directory || undefined,
|
||||
addedOn: undefined, // rtorrent does not expose added time via multicall2
|
||||
arrQueueId: arrInfo.queueId,
|
||||
arrType: arrInfo.type,
|
||||
raw: torrent
|
||||
};
|
||||
}
|
||||
|
||||
_mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) {
|
||||
if (isHashChecking === 1) {
|
||||
return 'Checking';
|
||||
}
|
||||
|
||||
if (state === 0) {
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
if (isActive === 1) {
|
||||
return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading';
|
||||
}
|
||||
|
||||
return 'Paused';
|
||||
}
|
||||
|
||||
_extractArrInfo(filename) {
|
||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||
if (seriesMatch) {
|
||||
return { type: 'series' };
|
||||
}
|
||||
|
||||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||||
if (movieMatch) {
|
||||
return { type: 'movie' };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RTorrentClient;
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class SABnzbdClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'sabnzbd';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
const response = await this.makeRequest('', { mode: 'version' });
|
||||
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(additionalParams = {}, config = {}) {
|
||||
const params = {
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
...additionalParams
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api`, {
|
||||
params,
|
||||
...config
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
logToFile(`[SABnzbd:${this.name}] API request failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Get both queue and history to provide complete picture
|
||||
const [queueResponse, historyResponse] = await Promise.all([
|
||||
this.makeRequest({ mode: 'queue' }),
|
||||
this.makeRequest({ mode: 'history', limit: 10 })
|
||||
]);
|
||||
|
||||
const queueData = queueResponse.data;
|
||||
const historyData = historyResponse.data;
|
||||
|
||||
const downloads = [];
|
||||
|
||||
// Process active queue items
|
||||
if (queueData.queue && queueData.queue.slots) {
|
||||
for (const slot of queueData.queue.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'queue'));
|
||||
}
|
||||
}
|
||||
|
||||
// Process recent history items (last 10)
|
||||
if (historyData.history && historyData.history.slots) {
|
||||
for (const slot of historyData.history.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'history'));
|
||||
}
|
||||
}
|
||||
|
||||
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
|
||||
return downloads;
|
||||
} catch (error) {
|
||||
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const response = await this.makeRequest({ mode: 'queue' });
|
||||
const queueData = response.data.queue;
|
||||
|
||||
if (!queueData) return null;
|
||||
|
||||
return {
|
||||
status: queueData.status,
|
||||
speed: queueData.speed,
|
||||
kbpersec: queueData.kbpersec,
|
||||
sizeleft: queueData.sizeleft,
|
||||
mbleft: queueData.mbleft,
|
||||
mb: queueData.mb,
|
||||
diskspace1: queueData.diskspace1,
|
||||
diskspace2: queueData.diskspace2,
|
||||
loadavg: queueData.loadavg,
|
||||
pause_int: queueData.pause_int
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(slot, source) {
|
||||
const isHistory = source === 'history';
|
||||
|
||||
// Map SABnzbd statuses to normalized status
|
||||
const statusMap = {
|
||||
'Downloading': 'Downloading',
|
||||
'Paused': 'Paused',
|
||||
'Waiting': 'Queued',
|
||||
'Completed': 'Completed',
|
||||
'Failed': 'Error',
|
||||
'Verifying': 'Checking',
|
||||
'Extracting': 'Extracting',
|
||||
'Moving': 'Moving',
|
||||
'QuickCheck': 'Checking',
|
||||
'Repairing': 'Repairing'
|
||||
};
|
||||
|
||||
const status = statusMap[slot.status] || slot.status;
|
||||
|
||||
// Calculate progress
|
||||
let progress = 0;
|
||||
let downloaded = 0;
|
||||
let size = 0;
|
||||
|
||||
if (slot.mb && slot.mbleft !== undefined) {
|
||||
size = slot.mb * 1024 * 1024; // Convert MB to bytes
|
||||
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
|
||||
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 0;
|
||||
} else if (slot.size) {
|
||||
// Try to parse size string (e.g., "1.5 GB")
|
||||
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
|
||||
if (sizeMatch) {
|
||||
const [, sizeValue, sizeUnit] = sizeMatch;
|
||||
const multiplier = this.getUnitMultiplier(sizeUnit);
|
||||
size = parseFloat(sizeValue) * multiplier;
|
||||
|
||||
if (slot.sizeleft) {
|
||||
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
|
||||
if (leftMatch) {
|
||||
const [, leftValue, leftUnit] = leftMatch;
|
||||
const leftMultiplier = this.getUnitMultiplier(leftUnit);
|
||||
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
|
||||
progress = size > 0 ? (downloaded / size) * 100 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Sonarr/Radarr info from nzb_name if present
|
||||
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
|
||||
|
||||
return {
|
||||
id: slot.nzo_id || slot.id,
|
||||
title: slot.filename || slot.nzb_name || 'Unknown',
|
||||
type: 'usenet',
|
||||
client: 'sabnzbd',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: status,
|
||||
progress: Math.round(progress),
|
||||
size: Math.round(size),
|
||||
downloaded: Math.round(downloaded),
|
||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||
category: slot.cat || undefined,
|
||||
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
|
||||
savePath: slot.final_name || undefined,
|
||||
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
||||
arrQueueId: arrInfo.queueId,
|
||||
arrType: arrInfo.type,
|
||||
raw: { ...slot, source }
|
||||
};
|
||||
}
|
||||
|
||||
getUnitMultiplier(unit) {
|
||||
const unitMap = {
|
||||
'b': 1,
|
||||
'byte': 1,
|
||||
'bytes': 1,
|
||||
'kb': 1024,
|
||||
'k': 1024,
|
||||
'mb': 1024 * 1024,
|
||||
'm': 1024 * 1024,
|
||||
'gb': 1024 * 1024 * 1024,
|
||||
'g': 1024 * 1024 * 1024,
|
||||
'tb': 1024 * 1024 * 1024 * 1024,
|
||||
't': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
return unitMap[unit.toLowerCase()] || 1;
|
||||
}
|
||||
|
||||
calculateEta(timeLeft) {
|
||||
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse time in various formats: "0:05:30", "15:30", "330"
|
||||
const parts = timeLeft.split(':').reverse();
|
||||
let totalSeconds = 0;
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Just seconds
|
||||
totalSeconds = parseInt(parts[0], 10);
|
||||
} else if (parts.length === 2) {
|
||||
// MM:SS
|
||||
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
|
||||
} else if (parts.length === 3) {
|
||||
// HH:MM:SS
|
||||
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
|
||||
}
|
||||
|
||||
return isNaN(totalSeconds) ? null : totalSeconds;
|
||||
}
|
||||
|
||||
extractArrInfo(filename) {
|
||||
// Try to extract Sonarr/Radarr info from filename patterns
|
||||
// This is a simple implementation - could be enhanced with regex patterns
|
||||
|
||||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||
if (seriesMatch) {
|
||||
return { type: 'series' };
|
||||
}
|
||||
|
||||
// Look for movie year patterns like "Movie Title (2023)"
|
||||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||||
if (movieMatch && !seriesMatch) {
|
||||
return { type: 'movie' };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SABnzbdClient;
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class TransmissionClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
this.sessionId = null;
|
||||
this.rpcUrl = `${this.url}/transmission/rpc`;
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'transmission';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.makeRequest('session-get');
|
||||
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(method, arguments_ = {}, config = {}) {
|
||||
const payload = {
|
||||
method,
|
||||
arguments: arguments_
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.sessionId) {
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.rpcUrl, payload, {
|
||||
headers,
|
||||
...config
|
||||
});
|
||||
|
||||
if (response.data.result !== 'success') {
|
||||
throw new Error(`Transmission RPC error: ${response.data.result}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Handle session ID conflict (409 Conflict)
|
||||
if (error.response && error.response.status === 409) {
|
||||
const sessionId = error.response.headers['x-transmission-session-id'];
|
||||
if (sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
logToFile(`[Transmission:${this.name}] Updated session ID`);
|
||||
return this.makeRequest(method, arguments_, config);
|
||||
}
|
||||
}
|
||||
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Get all torrents with detailed fields
|
||||
const response = await this.makeRequest('torrent-get', {
|
||||
fields: [
|
||||
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
|
||||
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
|
||||
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
|
||||
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
|
||||
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
|
||||
]
|
||||
});
|
||||
|
||||
const torrents = response.data.arguments.torrents || [];
|
||||
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const response = await this.makeRequest('session-get');
|
||||
const sessionStats = await this.makeRequest('session-stats');
|
||||
|
||||
return {
|
||||
session: response.data.arguments,
|
||||
stats: sessionStats.data.arguments
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
// Map Transmission status codes to normalized status
|
||||
const statusMap = {
|
||||
0: 'Stopped', // TORRENT_STOPPED
|
||||
1: 'Queued', // TORRENT_CHECK_WAIT
|
||||
2: 'Checking', // TORRENT_CHECK
|
||||
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
|
||||
4: 'Downloading', // TORRENT_DOWNLOAD
|
||||
5: 'Queued', // TORRENT_SEED_WAIT
|
||||
6: 'Seeding', // TORRENT_SEED
|
||||
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
|
||||
};
|
||||
|
||||
const status = statusMap[torrent.status] || 'Unknown';
|
||||
|
||||
// Calculate progress and sizes
|
||||
const progress = torrent.percentDone * 100;
|
||||
const size = torrent.totalSize;
|
||||
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
|
||||
|
||||
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
|
||||
let eta = null;
|
||||
if (torrent.eta >= 0) {
|
||||
eta = torrent.eta;
|
||||
}
|
||||
|
||||
// Extract category/labels
|
||||
const labels = torrent.labels || [];
|
||||
const category = labels.length > 0 ? labels[0] : undefined;
|
||||
|
||||
// Try to extract Sonarr/Radarr info from name
|
||||
const arrInfo = this.extractArrInfo(torrent.name);
|
||||
|
||||
return {
|
||||
id: torrent.hashString,
|
||||
title: torrent.name,
|
||||
type: 'torrent',
|
||||
client: 'transmission',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: status,
|
||||
progress: Math.round(progress),
|
||||
size: size,
|
||||
downloaded: downloaded,
|
||||
speed: torrent.rateDownload,
|
||||
eta: eta,
|
||||
category: category,
|
||||
tags: labels,
|
||||
savePath: torrent.downloadDir || undefined,
|
||||
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
|
||||
arrQueueId: arrInfo.queueId,
|
||||
arrType: arrInfo.type,
|
||||
raw: torrent
|
||||
};
|
||||
}
|
||||
|
||||
extractArrInfo(filename) {
|
||||
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
|
||||
|
||||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||
if (seriesMatch) {
|
||||
return { type: 'series' };
|
||||
}
|
||||
|
||||
// Look for movie year patterns like "Movie Title (2023)"
|
||||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||||
if (movieMatch && !seriesMatch) {
|
||||
return { type: 'movie' };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TransmissionClient;
|
||||
+281
-12
@@ -1,16 +1,45 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
|
||||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
||||
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
|
||||
// Log file lives in DATA_DIR so the non-root container user can write to it
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const LOG_PATH = path.join(DATA_DIR, 'server.log');
|
||||
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
||||
const LOG_KEEP = 3; // keep 3 rotated files
|
||||
|
||||
function rotateLogIfNeeded() {
|
||||
try {
|
||||
const stat = fs.statSync(LOG_PATH);
|
||||
if (stat.size < LOG_MAX_BYTES) return;
|
||||
for (let i = LOG_KEEP - 1; i >= 1; i--) {
|
||||
const src = `${LOG_PATH}.${i}`;
|
||||
const dst = `${LOG_PATH}.${i + 1}`;
|
||||
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
||||
}
|
||||
fs.renameSync(LOG_PATH, `${LOG_PATH}.1`);
|
||||
} catch { /* ignore rotation errors — don't crash the server */ }
|
||||
}
|
||||
|
||||
rotateLogIfNeeded();
|
||||
const logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
@@ -53,31 +82,271 @@ const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup environment validation
|
||||
// ---------------------------------------------------------------------------
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Security] COOKIE_SECRET is not set in production — aborting.');
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
|
||||
} else if (cookieSecret.length < 32) {
|
||||
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
|
||||
}
|
||||
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Config] EMBY_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.EMBY_URL) {
|
||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helmet v7 — security response headers
|
||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
// Generate a fresh nonce for every request
|
||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// General API rate limiter — applies to all /api/* routes
|
||||
// More specific limiters (e.g. login) apply on top of this.
|
||||
// ---------------------------------------------------------------------------
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body parsing & cookies
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health / readiness endpoints (no auth, no rate-limit)
|
||||
// Used by Docker HEALTHCHECK and orchestrators.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
// Confirm critical config is present
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
res.json({ status: 'ready' });
|
||||
} else {
|
||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files — served before API routes
|
||||
// index.html is served manually so we can inject the CSP nonce
|
||||
// ---------------------------------------------------------------------------
|
||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||
|
||||
// Serve all static assets (js, css, images, icons) except index.html.
|
||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||
app.use(express.static(PUBLIC_DIR, {
|
||||
index: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve index.html with CSP nonce injected into <script> tags
|
||||
function serveIndex(req, res) {
|
||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||
if (err) return res.status(500).send('Internal Server Error');
|
||||
const nonce = res.locals.cspNonce;
|
||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||
// the old nonce which no longer matches the per-request CSP header).
|
||||
const patched = html
|
||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(patched);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
||||
// CSRF protection applies to all state-changing /api/* requests except
|
||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
app.use('/api', verifyCsrf);
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
// SPA catch-all — serve index.html for any unmatched path
|
||||
app.get('*', serveIndex);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global error handler — never leak stack traces to clients
|
||||
// ---------------------------------------------------------------------------
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Server] Unhandled error:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS / HTTPS support
|
||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||
// If unset, defaults to the bundled snakeoil self-signed certificate
|
||||
// (localhost/127.0.0.1 only — suitable for local testing).
|
||||
// Set TLS_ENABLED=false to force plain HTTP even if cert files exist.
|
||||
// ---------------------------------------------------------------------------
|
||||
const CERTS_DIR = path.join(__dirname, '../certs');
|
||||
const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt');
|
||||
const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key');
|
||||
|
||||
function loadTlsCredentials() {
|
||||
if (!TLS_ENABLED) return null;
|
||||
try {
|
||||
return {
|
||||
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||
key: fs.readFileSync(TLS_KEY_PATH)
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const tlsCredentials = loadTlsCredentials();
|
||||
const server = tlsCredentials
|
||||
? https.createServer(tlsCredentials, app)
|
||||
: http.createServer(app);
|
||||
|
||||
const protocol = tlsCredentials ? 'https' : 'http';
|
||||
const isSnakeoil = TLS_ENABLED &&
|
||||
(!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`=================================`);
|
||||
console.log(` sofarr - Your Downloads Dashboard`);
|
||||
console.log(` Server running on port ${PORT}`);
|
||||
console.log(` sofarr v${version} - Your Downloads Dashboard`);
|
||||
console.log(` Server running on ${protocol}://localhost:${PORT}`);
|
||||
if (tlsCredentials && isSnakeoil) {
|
||||
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
|
||||
console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`);
|
||||
console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`);
|
||||
} else if (tlsCredentials) {
|
||||
console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`);
|
||||
} else {
|
||||
console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`);
|
||||
}
|
||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
|
||||
// Stop the poller, close the HTTP server (stops accepting new connections),
|
||||
// then let Node drain existing keep-alive connections and exit cleanly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const { stopPoller } = require('./utils/poller');
|
||||
|
||||
function shutdown(signal) {
|
||||
console.log(`[Server] ${signal} received — shutting down gracefully`);
|
||||
stopPoller();
|
||||
server.close(() => {
|
||||
console.log('[Server] HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
// Force exit after 10 s if connections don't drain
|
||||
setTimeout(() => {
|
||||
console.error('[Server] Forced exit after 10 s timeout');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
function requireAuth(req, res, next) {
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (!raw || raw === false) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
let u;
|
||||
try {
|
||||
u = JSON.parse(raw);
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
// Schema validation
|
||||
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
|
||||
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
|
||||
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
|
||||
req.user = u;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = requireAuth;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* CSRF protection using the double-submit cookie pattern.
|
||||
*
|
||||
* On login the server issues a random `csrf_token` cookie (httpOnly:false
|
||||
* so JS can read it). The SPA must send the same value in the
|
||||
* `X-CSRF-Token` request header for every state-changing request (POST,
|
||||
* PUT, PATCH, DELETE).
|
||||
*
|
||||
* Because the `sameSite: strict` session cookie already provides strong
|
||||
* protection in modern browsers, this acts as defence-in-depth for
|
||||
* older browsers and any edge cases.
|
||||
*
|
||||
* Safe methods (GET, HEAD, OPTIONS) are exempted.
|
||||
*/
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function verifyCsrf(req, res, next) {
|
||||
if (SAFE_METHODS.has(req.method)) return next();
|
||||
|
||||
const cookieToken = req.cookies.csrf_token;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken) {
|
||||
return res.status(403).json({ error: 'CSRF token missing' });
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (cookieToken.length !== headerToken.length) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = verifyCsrf;
|
||||
+139
-45
@@ -1,57 +1,108 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
// Persistent JSON file-backed token store — survives restarts
|
||||
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
|
||||
|
||||
// Read EMBY_URL at request time (not module load time) so the value
|
||||
// can be overridden by environment variables set after the module loads.
|
||||
const getEmbyUrl = () => process.env.EMBY_URL;
|
||||
|
||||
// Strict login limiter: 10 attempts per 15 min, then locked for the window.
|
||||
// Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from
|
||||
// interfering with integration tests (all requests come from 127.0.0.1).
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // only count failures toward the limit
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
// Authenticate user with Emby
|
||||
router.post('/login', async (req, res) => {
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const { username, password, rememberMe } = req.body;
|
||||
|
||||
// Input validation — reject obviously invalid inputs before hitting Emby
|
||||
if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid username' });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length === 0 || password.length > 256) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid password' });
|
||||
}
|
||||
|
||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
||||
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
|
||||
|
||||
// Authenticate with Emby
|
||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
// Authenticate with Emby using a stable DeviceId derived from the username.
|
||||
// Using a deterministic DeviceId causes Emby to reuse the existing session
|
||||
// for this device rather than creating a new one on each login.
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, {
|
||||
Username: username.trim(),
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
|
||||
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||
}
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
|
||||
|
||||
// Get user info using the access token
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
}
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
console.log(`[Auth] User info:`, JSON.stringify(user));
|
||||
console.log(`[Auth] Login successful for user: ${user.Name}`);
|
||||
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
|
||||
|
||||
// Store token server-side; it is never sent to the client.
|
||||
storeToken(user.Id, authData.AccessToken);
|
||||
|
||||
// Set authentication cookie
|
||||
res.cookie('emby_user', JSON.stringify({
|
||||
id: user.Id,
|
||||
name: user.Name,
|
||||
token: authData.AccessToken
|
||||
}), {
|
||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||
// rememberMe=true → persistent cookie, expires in 30 days
|
||||
// rememberMe=false → session cookie, expires when browser closes
|
||||
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
|
||||
// proxy is in front. Without it the app may be accessed over plain HTTP and
|
||||
// secure cookies would never be sent back by the browser.
|
||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
signed,
|
||||
path: '/'
|
||||
};
|
||||
if (rememberMe) {
|
||||
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
}
|
||||
res.cookie('emby_user', cookiePayload, cookieOptions);
|
||||
|
||||
// Issue a CSRF token tied to this session so state-changing endpoints
|
||||
// can validate the double-submit cookie pattern
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.Id,
|
||||
name: user.Name
|
||||
}
|
||||
user: { id: user.Id, name: user.Name, isAdmin },
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Login failed:`, error.message);
|
||||
@@ -62,32 +113,75 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseSessionCookie(req) {
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (!raw || raw === false) return null; // false = tampered signed cookie
|
||||
try {
|
||||
const u = JSON.parse(raw);
|
||||
// Schema validation: require id (string), name (string), isAdmin (boolean)
|
||||
if (typeof u.id !== 'string' || !u.id) return null;
|
||||
if (typeof u.name !== 'string' || !u.name) return null;
|
||||
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
|
||||
return u;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current authenticated user
|
||||
router.get('/me', (req, res) => {
|
||||
try {
|
||||
const userCookie = req.cookies.emby_user;
|
||||
|
||||
if (!userCookie) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const user = JSON.parse(userCookie);
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Error getting current user:`, error.message);
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
const user = parseSessionCookie(req);
|
||||
if (!user) return res.json({ authenticated: false });
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
|
||||
});
|
||||
});
|
||||
|
||||
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||
// (e.g. after a page reload where the JS variable was lost)
|
||||
router.get('/csrf', (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) => {
|
||||
res.clearCookie('emby_user');
|
||||
router.post('/logout', async (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
if (user) {
|
||||
const stored = getToken(user.id);
|
||||
if (stored) {
|
||||
try {
|
||||
await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, {
|
||||
headers: { 'X-MediaBrowser-Token': stored.accessToken }
|
||||
});
|
||||
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
|
||||
} catch (err) {
|
||||
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
|
||||
}
|
||||
clearToken(user.id);
|
||||
}
|
||||
}
|
||||
res.clearCookie('emby_user', {
|
||||
httpOnly: true,
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
signed: !!process.env.COOKIE_SECRET,
|
||||
path: '/'
|
||||
});
|
||||
res.clearCookie('csrf_token', {
|
||||
httpOnly: false,
|
||||
secure: !!process.env.TRUST_PROXY,
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
+816
-291
File diff suppressed because it is too large
Load Diff
+18
-16
@@ -1,51 +1,53 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get active sessions
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user by session ID
|
||||
router.get('/session/:sessionId/user', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
|
||||
const session = response.data.find(s => s.Id === req.params.sessionId);
|
||||
@@ -53,13 +55,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
|
||||
res.json(userResponse.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||
// from a shared location. For now they are inlined here to keep dashboard.js
|
||||
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
|
||||
// into server/utils/dashboardHelpers.js in a later refactor.
|
||||
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
if (tagLower === username) return true;
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const embyUrl = process.env.EMBY_URL;
|
||||
const embyKey = process.env.EMBY_API_KEY;
|
||||
if (!embyUrl || !embyKey) return new Map();
|
||||
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
|
||||
const users = res.data || [];
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
if (!u.Name) continue;
|
||||
const lower = u.Name.toLowerCase();
|
||||
map.set(lower, u.Name);
|
||||
map.set(sanitizeTagLabel(lower), u.Name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[History] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser };
|
||||
});
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr history record.
|
||||
function extractEpisode(record) {
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all history records
|
||||
// that share the same source title. Returns sorted, deduplicated array.
|
||||
function gatherEpisodes(titleLower, records) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of records) {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate history items so that for each unique content item (episode or
|
||||
* movie) only the most-recent record is shown, with the following rules:
|
||||
*
|
||||
* - If the most recent event is 'imported' → show it; suppress older failures.
|
||||
* - If the most recent event is 'failed' and the item currently has a file
|
||||
* (hasFile = true) → show the failure but flag it as availableForUpgrade:true
|
||||
* so the UI can indicate the item is available but an upgrade is in progress.
|
||||
* - If the most recent event is 'failed' and hasFile is false → show normally.
|
||||
*
|
||||
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
|
||||
* Records without a contentId fall through unchanged (no deduplication possible).
|
||||
*
|
||||
* @param {Array} items - Already-built history items (unsorted)
|
||||
* @param {Array} sonarrRaw - Raw Sonarr records (for hasFile lookup)
|
||||
* @param {Array} radarrRaw - Raw Radarr records (for hasFile lookup)
|
||||
* @returns {Array}
|
||||
*/
|
||||
function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
|
||||
// Build hasFile lookup: contentId → boolean
|
||||
const sonarrHasFile = new Map();
|
||||
for (const r of sonarrRaw) {
|
||||
const id = r.episodeId;
|
||||
if (id != null) {
|
||||
const hf = r.episode && r.episode.hasFile != null ? r.episode.hasFile : undefined;
|
||||
if (hf !== undefined && !sonarrHasFile.has(id)) sonarrHasFile.set(id, hf);
|
||||
}
|
||||
}
|
||||
const radarrHasFile = new Map();
|
||||
for (const r of radarrRaw) {
|
||||
const id = r.movieId;
|
||||
if (id != null) {
|
||||
const hf = r.movie && r.movie.hasFile != null ? r.movie.hasFile : undefined;
|
||||
if (hf !== undefined && !radarrHasFile.has(id)) radarrHasFile.set(id, hf);
|
||||
}
|
||||
}
|
||||
|
||||
// Group items by dedup key; preserve insertion order (newest first from caller)
|
||||
const groups = new Map();
|
||||
const noKey = [];
|
||||
for (const item of items) {
|
||||
const cid = item._contentId;
|
||||
if (cid == null) { noKey.push(item); continue; }
|
||||
const key = `${item.type}|${item.instanceName}|${cid}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(item);
|
||||
}
|
||||
|
||||
const result = [...noKey];
|
||||
for (const [, group] of groups) {
|
||||
// group[0] is the most recent (items are pushed in date-descending order)
|
||||
const best = group[0];
|
||||
if (best.outcome === 'imported') {
|
||||
result.push(best);
|
||||
continue;
|
||||
}
|
||||
if (best.outcome === 'failed') {
|
||||
const hasFile = best.type === 'series'
|
||||
? sonarrHasFile.get(best._contentId)
|
||||
: radarrHasFile.get(best._contentId);
|
||||
if (hasFile) best.availableForUpgrade = true;
|
||||
result.push(best);
|
||||
continue;
|
||||
}
|
||||
result.push(best);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/recent
|
||||
*
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||
* (default 7, overridable via env or ?days= query param).
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* user: string,
|
||||
* isAdmin: boolean,
|
||||
* days: number,
|
||||
* history: HistoryItem[]
|
||||
* }
|
||||
*
|
||||
* HistoryItem shape:
|
||||
* {
|
||||
* type: 'series'|'movie',
|
||||
* outcome: 'imported'|'failed',
|
||||
* title: string, // sourceTitle from arr record
|
||||
* seriesName?: string, // series.title (Sonarr)
|
||||
* movieName?: string, // movie.title (Radarr)
|
||||
* coverArt: string|null,
|
||||
* completedAt: string, // ISO date string from arr record
|
||||
* quality: string|null,
|
||||
* instanceName: string, // arr instance name
|
||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||
* allTags: string[],
|
||||
* matchedUserTag: string|null,
|
||||
* // admin-only:
|
||||
* arrRecordId?: number,
|
||||
* failureMessage?: string,
|
||||
* }
|
||||
*/
|
||||
router.get('/recent', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
|
||||
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
|
||||
const requestedDays = parseInt(req.query.days, 10);
|
||||
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
|
||||
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch tag maps and history in parallel
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
|
||||
fetchSonarrHistory(since),
|
||||
fetchRadarrHistory(since),
|
||||
showAll ? getEmbyUsers() : Promise.resolve(new Map())
|
||||
]);
|
||||
|
||||
// Build tag maps from the cached poll data where available,
|
||||
// falling back to what's embedded in history records
|
||||
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
|
||||
|
||||
const historyItems = [];
|
||||
|
||||
// --- Sonarr history ---
|
||||
for (const record of sonarrHistory) {
|
||||
try {
|
||||
const outcome = classifySonarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const series = record.series;
|
||||
if (!series) continue;
|
||||
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const sourceTitle = record.sourceTitle || record.title || series.title;
|
||||
const item = {
|
||||
type: 'series',
|
||||
outcome,
|
||||
title: sourceTitle,
|
||||
seriesName: series.title,
|
||||
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||
coverArt: getCoverArt(series),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getSonarrLink(series),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
_contentId: record.episodeId != null ? record.episodeId : null
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Sonarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Radarr history ---
|
||||
for (const record of radarrHistory) {
|
||||
try {
|
||||
const outcome = classifyRadarrEvent(record.eventType);
|
||||
if (outcome === 'other') continue;
|
||||
|
||||
const movie = record.movie;
|
||||
if (!movie) continue;
|
||||
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
|
||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||
|
||||
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||
? record.quality.quality.name
|
||||
: null;
|
||||
|
||||
const item = {
|
||||
type: 'movie',
|
||||
outcome,
|
||||
title: record.sourceTitle || record.title || movie.title,
|
||||
movieName: movie.title,
|
||||
coverArt: getCoverArt(movie),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getRadarrLink(movie),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
_contentId: record.movieId != null ? record.movieId : null
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
}
|
||||
|
||||
historyItems.push(item);
|
||||
} catch (err) {
|
||||
console.error('[History] Error processing Radarr record:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: for each content item keep only the most-recent record,
|
||||
// suppressing failures that were superseded by a successful import.
|
||||
// Must run before sort so insertion order (newest-first from arr API) is preserved.
|
||||
const dedupedItems = deduplicateHistoryItems(historyItems, sonarrHistory, radarrHistory);
|
||||
|
||||
// Strip internal dedup key before sending to client
|
||||
for (const item of dedupedItems) delete item._contentId;
|
||||
|
||||
// Sort newest first
|
||||
dedupedItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||
|
||||
console.log(`[History] Returning ${dedupedItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||
|
||||
res.json({
|
||||
user: user.name,
|
||||
isAdmin,
|
||||
days,
|
||||
history: dedupedItems
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[History] Error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+16
-14
@@ -1,56 +1,58 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const RADARR_URL = process.env.RADARR_URL;
|
||||
const RADARR_API_KEY = process.env.RADARR_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY },
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get movie details
|
||||
router.get('/movies/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all movies with tags
|
||||
router.get('/movies', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const SABNZBD_URL = process.env.SABNZBD_URL;
|
||||
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get current queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
apikey: SABNZBD_API_KEY,
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
output: 'json'
|
||||
}
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
params: {
|
||||
mode: 'history',
|
||||
apikey: SABNZBD_API_KEY,
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
output: 'json',
|
||||
limit: req.query.limit || 50
|
||||
}
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+16
-14
@@ -1,56 +1,58 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const SONARR_URL = process.env.SONARR_URL;
|
||||
const SONARR_API_KEY = process.env.SONARR_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY },
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get series details
|
||||
router.get('/series/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all series with tags
|
||||
router.get('/series', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
class MemoryCache {
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key, value, ttlMs) {
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(key) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
const entries = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const [key, entry] of this.store.entries()) {
|
||||
// Maps must be converted before JSON.stringify (which renders them as "{}")
|
||||
const serializable = entry.value instanceof Map ? Object.fromEntries(entry.value) : entry.value;
|
||||
const json = JSON.stringify(serializable);
|
||||
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||
totalSize += sizeBytes;
|
||||
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||
const expired = now > entry.expiresAt;
|
||||
let itemCount = null;
|
||||
if (entry.value instanceof Map) {
|
||||
itemCount = entry.value.size;
|
||||
} else if (Array.isArray(entry.value)) {
|
||||
itemCount = entry.value.length;
|
||||
} else if (entry.value && typeof entry.value === 'object') {
|
||||
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
sizeBytes,
|
||||
itemCount,
|
||||
ttlRemainingMs: ttlRemaining,
|
||||
expired
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
entryCount: this.store.size,
|
||||
totalSizeBytes: totalSize,
|
||||
entries
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new MemoryCache();
|
||||
|
||||
module.exports = cache;
|
||||
+53
-5
@@ -1,5 +1,28 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
// Validate that a configured service URL is well-formed and uses http(s).
|
||||
// Emits a warning (never throws) so a misconfigured instance degrades
|
||||
// gracefully rather than crashing the whole server.
|
||||
function validateInstanceUrl(url, instanceId) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
|
||||
return false;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
|
||||
// Try to parse JSON array format first
|
||||
if (envVar) {
|
||||
@@ -9,10 +32,11 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
const instances = JSON.parse(cleaned);
|
||||
if (Array.isArray(instances) && instances.length > 0) {
|
||||
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
|
||||
return instances.map((inst, idx) => ({
|
||||
...inst,
|
||||
id: inst.name || `instance-${idx + 1}`
|
||||
}));
|
||||
return instances.map((inst, idx) => {
|
||||
const id = inst.name || `instance-${idx + 1}`;
|
||||
validateInstanceUrl(inst.url, id);
|
||||
return { ...inst, id };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
|
||||
@@ -22,6 +46,7 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
|
||||
// Fall back to legacy single-instance format
|
||||
if (legacyUrl && legacyKey) {
|
||||
logToFile(`[Config] Using legacy single-instance format`);
|
||||
validateInstanceUrl(legacyUrl, 'default');
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
@@ -69,10 +94,33 @@ function getQbittorrentInstances() {
|
||||
);
|
||||
}
|
||||
|
||||
function getTransmissionInstances() {
|
||||
return parseInstances(
|
||||
process.env.TRANSMISSION_INSTANCES,
|
||||
process.env.TRANSMISSION_URL,
|
||||
null, // no apiKey for Transmission
|
||||
process.env.TRANSMISSION_USERNAME,
|
||||
process.env.TRANSMISSION_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
function getRtorrentInstances() {
|
||||
return parseInstances(
|
||||
process.env.RTORRENT_INSTANCES,
|
||||
process.env.RTORRENT_URL,
|
||||
null, // no apiKey for rtorrent
|
||||
process.env.RTORRENT_USERNAME,
|
||||
process.env.RTORRENT_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getQbittorrentInstances,
|
||||
parseInstances
|
||||
getTransmissionInstances,
|
||||
getRtorrentInstances,
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
const {
|
||||
getSABnzbdInstances,
|
||||
getQbittorrentInstances,
|
||||
getTransmissionInstances,
|
||||
getRtorrentInstances
|
||||
} = require('./config');
|
||||
|
||||
// Import client classes
|
||||
const SABnzbdClient = require('../clients/SABnzbdClient');
|
||||
const QBittorrentClient = require('../clients/QBittorrentClient');
|
||||
const TransmissionClient = require('../clients/TransmissionClient');
|
||||
const RTorrentClient = require('../clients/RTorrentClient');
|
||||
|
||||
// Client type mapping
|
||||
const clientClasses = {
|
||||
sabnzbd: SABnzbdClient,
|
||||
qbittorrent: QBittorrentClient,
|
||||
transmission: TransmissionClient,
|
||||
rtorrent: RTorrentClient
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry and factory for download clients
|
||||
*/
|
||||
class DownloadClientRegistry {
|
||||
constructor() {
|
||||
this.clients = new Map();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all configured download clients
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile('[DownloadClientRegistry] Initializing download clients...');
|
||||
|
||||
// Get all instance configurations
|
||||
const sabnzbdInstances = getSABnzbdInstances();
|
||||
const qbittorrentInstances = getQbittorrentInstances();
|
||||
const transmissionInstances = getTransmissionInstances();
|
||||
const rtorrentInstances = getRtorrentInstances();
|
||||
|
||||
// Create client instances
|
||||
const instanceConfigs = [
|
||||
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
|
||||
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
|
||||
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
|
||||
...rtorrentInstances.map(inst => ({ ...inst, type: 'rtorrent' }))
|
||||
];
|
||||
|
||||
for (const config of instanceConfigs) {
|
||||
try {
|
||||
const ClientClass = clientClasses[config.type];
|
||||
if (!ClientClass) {
|
||||
logToFile(`[DownloadClientRegistry] Unknown client type: ${config.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = new ClientClass(config);
|
||||
this.clients.set(config.id, client);
|
||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logToFile(`[DownloadClientRegistry] Initialized ${this.clients.size} download clients`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered clients
|
||||
* @returns {Array<DownloadClient>} Array of client instances
|
||||
*/
|
||||
getAllClients() {
|
||||
return Array.from(this.clients.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client by instance ID
|
||||
* @param {string} instanceId - The instance ID
|
||||
* @returns {DownloadClient|null} Client instance or null if not found
|
||||
*/
|
||||
getClient(instanceId) {
|
||||
return this.clients.get(instanceId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients by type
|
||||
* @param {string} type - Client type ('sabnzbd', 'qbittorrent', 'transmission')
|
||||
* @returns {Array<DownloadClient>} Array of client instances
|
||||
*/
|
||||
getClientsByType(type) {
|
||||
return this.getAllClients().filter(client => client.getClientType() === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active downloads from all clients
|
||||
* @returns {Promise<Array<NormalizedDownload>>} Array of all downloads
|
||||
*/
|
||||
async getAllDownloads() {
|
||||
const clients = this.getAllClients();
|
||||
if (clients.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reset fallback flags for qBittorrent clients
|
||||
for (const client of clients) {
|
||||
if (client.resetFallbackFlag) {
|
||||
client.resetFallbackFlag();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch downloads from all clients in parallel
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async (client) => {
|
||||
try {
|
||||
const downloads = await client.getActiveDownloads();
|
||||
logToFile(`[DownloadClientRegistry] ${client.name}: ${downloads.length} downloads`);
|
||||
return downloads;
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Flatten and return all downloads
|
||||
const allDownloads = results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.flatMap(result => result.value);
|
||||
|
||||
logToFile(`[DownloadClientRegistry] Total downloads from all clients: ${allDownloads.length}`);
|
||||
return allDownloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get downloads grouped by client type (for backward compatibility)
|
||||
* @returns {Promise<Object>} Downloads grouped by client type
|
||||
*/
|
||||
async getDownloadsByClientType() {
|
||||
const clients = this.getAllClients();
|
||||
const result = {};
|
||||
|
||||
// Group by client type
|
||||
for (const client of clients) {
|
||||
const type = client.getClientType();
|
||||
if (!result[type]) {
|
||||
result[type] = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const downloads = await client.getActiveDownloads();
|
||||
result[type].push(...downloads);
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to all clients
|
||||
* @returns {Promise<Array<Object>>} Array of connection test results
|
||||
*/
|
||||
async testAllConnections() {
|
||||
const clients = this.getAllClients();
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async (client) => {
|
||||
try {
|
||||
const success = await client.testConnection();
|
||||
return {
|
||||
instanceId: client.getInstanceId(),
|
||||
instanceName: client.name,
|
||||
clientType: client.getClientType(),
|
||||
success,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
instanceId: client.getInstanceId(),
|
||||
instanceName: client.name,
|
||||
clientType: client.getClientType(),
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client status information from all clients
|
||||
* @returns {Promise<Array<Object>>} Array of client status objects
|
||||
*/
|
||||
async getAllClientStatuses() {
|
||||
const clients = this.getAllClients();
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async (client) => {
|
||||
try {
|
||||
const status = await client.getClientStatus();
|
||||
return {
|
||||
instanceId: client.getInstanceId(),
|
||||
instanceName: client.name,
|
||||
clientType: client.getClientType(),
|
||||
status
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
|
||||
return {
|
||||
instanceId: client.getInstanceId(),
|
||||
instanceName: client.name,
|
||||
clientType: client.getClientType(),
|
||||
status: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const registry = new DownloadClientRegistry();
|
||||
|
||||
module.exports = {
|
||||
DownloadClientRegistry,
|
||||
registry,
|
||||
|
||||
// Convenience functions
|
||||
initializeClients: () => registry.initialize(),
|
||||
getAllClients: () => registry.getAllClients(),
|
||||
getClient: (instanceId) => registry.getClient(instanceId),
|
||||
getClientsByType: (type) => registry.getClientsByType(type),
|
||||
getAllDownloads: () => registry.getAllDownloads(),
|
||||
getDownloadsByClientType: () => registry.getDownloadsByClientType(),
|
||||
testAllConnections: () => registry.testAllConnections(),
|
||||
getAllClientStatuses: () => registry.getAllClientStatuses()
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||
|
||||
// Cache TTL for recent-history data: 5 minutes.
|
||||
// History changes slowly compared to active downloads.
|
||||
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Sonarr event types that represent a successful import
|
||||
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||
// Sonarr event types that represent a failed import
|
||||
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||
// Radarr equivalents
|
||||
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Sonarr instances for the given date window.
|
||||
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchSonarrHistory(since) {
|
||||
const cacheKey = 'history:sonarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const instances = getSonarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.series) r.series._instanceUrl = inst.url;
|
||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Radarr instances for the given date window.
|
||||
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchRadarrHistory(since) {
|
||||
const cacheKey = 'history:radarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const instances = getRadarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Sonarr history record's event type.
|
||||
* @param {string} eventType
|
||||
* @returns {'imported'|'failed'|'other'}
|
||||
*/
|
||||
function classifySonarrEvent(eventType) {
|
||||
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Radarr history record's event type.
|
||||
* @param {string} eventType
|
||||
* @returns {'imported'|'failed'|'other'}
|
||||
*/
|
||||
function classifyRadarrEvent(eventType) {
|
||||
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached history so the next request fetches fresh data.
|
||||
* Called externally if needed (e.g. after a forced refresh).
|
||||
*/
|
||||
function invalidateHistoryCache() {
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSonarrHistory,
|
||||
fetchRadarrHistory,
|
||||
classifySonarrEvent,
|
||||
classifyRadarrEvent,
|
||||
invalidateHistoryCache,
|
||||
HISTORY_CACHE_TTL
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
//
|
||||
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
||||
// read its contents from the file at that path and expose it as FOO.
|
||||
// This follows the standard *_FILE convention used by official Docker images.
|
||||
//
|
||||
// Supported secrets:
|
||||
// COOKIE_SECRET_FILE → COOKIE_SECRET
|
||||
// EMBY_API_KEY_FILE → EMBY_API_KEY
|
||||
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
|
||||
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
|
||||
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
|
||||
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
|
||||
//
|
||||
// For multi-instance JSON arrays the secret values must be embedded in the
|
||||
// JSON string itself; file-based loading is for the legacy single-key format.
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SECRET_MAPPINGS = [
|
||||
'COOKIE_SECRET',
|
||||
'EMBY_API_KEY',
|
||||
'SABNZBD_API_KEY',
|
||||
'SONARR_API_KEY',
|
||||
'RADARR_API_KEY',
|
||||
'QBITTORRENT_PASSWORD',
|
||||
];
|
||||
|
||||
function loadSecrets() {
|
||||
for (const key of SECRET_MAPPINGS) {
|
||||
const fileEnv = `${key}_FILE`;
|
||||
const filePath = process.env[fileEnv];
|
||||
if (!filePath) continue;
|
||||
if (process.env[key]) {
|
||||
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
|
||||
}
|
||||
try {
|
||||
const value = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!value) {
|
||||
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
|
||||
} catch (err) {
|
||||
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadSecrets;
|
||||
@@ -1,7 +1,13 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
|
||||
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
|
||||
// Falls back to ../../data/server.log (same directory index.js uses).
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
|
||||
|
||||
function logToFile(message) {
|
||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('./config');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||
? 0
|
||||
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
|
||||
const POLLING_ENABLED = POLL_INTERVAL > 0;
|
||||
|
||||
let polling = false;
|
||||
let lastPollTimings = null;
|
||||
|
||||
// SSE subscribers: Set of () => void callbacks, each registered by an open /stream connection
|
||||
const pollSubscribers = new Set();
|
||||
|
||||
function onPollComplete(cb) { pollSubscribers.add(cb); }
|
||||
function offPollComplete(cb) { pollSubscribers.delete(cb); }
|
||||
|
||||
// Timed fetch helper: runs a fetch and records how long it took
|
||||
async function timed(label, fn) {
|
||||
const t0 = Date.now();
|
||||
const result = await fn();
|
||||
return { label, result, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
async function pollAllServices() {
|
||||
if (polling) {
|
||||
console.log('[Poller] Previous poll still running, skipping');
|
||||
return;
|
||||
}
|
||||
polling = true;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Ensure download clients are initialized
|
||||
await initializeClients();
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// All fetches in parallel, each individually timed
|
||||
const results = await Promise.all([
|
||||
timed('Download Clients', async () => {
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
return downloadsByType;
|
||||
}),
|
||||
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
]);
|
||||
|
||||
const [
|
||||
{ result: downloadsByType },
|
||||
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||
{ result: sonarrHistories },
|
||||
{ result: radarrQueues }, { result: radarrHistories },
|
||||
{ result: radarrTagsResults }
|
||||
] = results;
|
||||
|
||||
// Store per-task timings
|
||||
const totalMs = Date.now() - start;
|
||||
lastPollTimings = {
|
||||
totalMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
|
||||
};
|
||||
|
||||
// When polling is active, TTL is 3x interval to avoid gaps between polls
|
||||
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
|
||||
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||
|
||||
// Download Clients (SABnzbd, qBittorrent, Transmission)
|
||||
// Preserve backward compatibility with existing cache keys
|
||||
const sabnzbdDownloads = downloadsByType.sabnzbd || [];
|
||||
const qbittorrentDownloads = downloadsByType.qbittorrent || [];
|
||||
|
||||
// SABnzbd - separate queue and history based on source
|
||||
const sabQueue = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'queue');
|
||||
const sabHistory = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'history');
|
||||
|
||||
// Transform SABnzbd downloads to legacy format for cache
|
||||
const sabQueueLegacy = {
|
||||
slots: sabQueue.map(d => ({
|
||||
nzo_id: d.id,
|
||||
filename: d.title,
|
||||
status: d.status,
|
||||
progress: d.progress / 100,
|
||||
mb: d.size / (1024 * 1024),
|
||||
mbleft: (d.size - d.downloaded) / (1024 * 1024),
|
||||
kbpersec: d.speed / 1024,
|
||||
timeleft: d.eta ? `${Math.floor(d.eta / 60)}:${String(Math.floor(d.eta % 60)).padStart(2, '0')}` : 'unknown',
|
||||
cat: d.category,
|
||||
labels: d.tags.join(','),
|
||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||
raw: d.raw
|
||||
}))
|
||||
};
|
||||
|
||||
const sabHistoryLegacy = {
|
||||
slots: sabHistory.map(d => ({
|
||||
nzo_id: d.id,
|
||||
filename: d.title,
|
||||
status: d.status,
|
||||
mb: d.size / (1024 * 1024),
|
||||
cat: d.category,
|
||||
labels: d.tags.join(','),
|
||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||
raw: d.raw
|
||||
}))
|
||||
};
|
||||
|
||||
// Extract status from first SABnzbd download if available
|
||||
const firstSabDownload = sabQueue[0];
|
||||
const sabStatus = firstSabDownload ? {
|
||||
status: 'Active',
|
||||
speed: firstSabDownload.speed,
|
||||
kbpersec: firstSabDownload.speed / 1024
|
||||
} : { status: 'Idle', speed: 0, kbpersec: 0 };
|
||||
|
||||
cache.set('poll:sab-queue', {
|
||||
...sabQueueLegacy,
|
||||
...sabStatus
|
||||
}, cacheTTL);
|
||||
|
||||
cache.set('poll:sab-history', sabHistoryLegacy, cacheTTL);
|
||||
|
||||
// qBittorrent - transform to legacy format
|
||||
const qbittorrentLegacy = qbittorrentDownloads.map(d => ({
|
||||
...d.raw,
|
||||
instanceId: d.instanceId,
|
||||
instanceName: d.instanceName
|
||||
}));
|
||||
|
||||
cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL);
|
||||
|
||||
// Sonarr
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
|
||||
// Radarr
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||
|
||||
// qBittorrent (already set above in download clients section)
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||
|
||||
// Notify all SSE stream connections so they push fresh data immediately
|
||||
for (const cb of pollSubscribers) {
|
||||
try { cb(); } catch { /* subscriber already disconnected */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Poll error:`, err.message);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
let intervalHandle = null;
|
||||
|
||||
function startPoller() {
|
||||
if (!POLLING_ENABLED) {
|
||||
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
|
||||
// Run immediately, then on interval
|
||||
pollAllServices();
|
||||
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPoller() {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
console.log('[Poller] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
function getLastPollTimings() {
|
||||
return lastPollTimings;
|
||||
}
|
||||
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLL_INTERVAL, POLLING_ENABLED };
|
||||
+42
-120
@@ -1,127 +1,47 @@
|
||||
const axios = require('axios');
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
// Legacy compatibility layer - delegates to new DownloadClient system
|
||||
const { logToFile } = require('./logger');
|
||||
const { getQbittorrentInstances } = require('./config');
|
||||
|
||||
class QBittorrentClient {
|
||||
constructor(instance) {
|
||||
this.id = instance.id;
|
||||
this.name = instance.name;
|
||||
this.url = instance.url;
|
||||
this.username = instance.username;
|
||||
this.password = instance.password;
|
||||
this.authCookie = null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
try {
|
||||
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
|
||||
const response = await axios.post(`${this.url}/api/v2/auth/login`,
|
||||
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
}
|
||||
);
|
||||
|
||||
if (response.headers['set-cookie']) {
|
||||
this.authCookie = response.headers['set-cookie'][0];
|
||||
logToFile(`[qBittorrent:${this.name}] Login successful`);
|
||||
return true;
|
||||
}
|
||||
|
||||
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(endpoint, config = {}) {
|
||||
const url = `${this.url}${endpoint}`;
|
||||
|
||||
if (!this.authCookie) {
|
||||
const loggedIn = await this.login();
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to authenticate with ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
// If unauthorized, try re-authenticating once
|
||||
if (error.response && error.response.status === 403) {
|
||||
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
|
||||
this.authCookie = null;
|
||||
const loggedIn = await this.login();
|
||||
if (loggedIn) {
|
||||
return axios.get(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...config.headers,
|
||||
'Cookie': this.authCookie
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTorrents() {
|
||||
try {
|
||||
const response = await this.makeRequest('/api/v2/torrents/info');
|
||||
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
|
||||
// Add instance info to each torrent
|
||||
return response.data.map(torrent => ({
|
||||
...torrent,
|
||||
instanceId: this.id,
|
||||
instanceName: this.name
|
||||
}));
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getClients() {
|
||||
const instances = getQbittorrentInstances();
|
||||
if (instances.length === 0) {
|
||||
logToFile('[qBittorrent] No instances configured');
|
||||
return [];
|
||||
}
|
||||
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
|
||||
return instances.map(inst => new QBittorrentClient(inst));
|
||||
}
|
||||
const { initializeClients, getClientsByType } = require('./downloadClients');
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility
|
||||
* Returns all torrents from all qBittorrent instances
|
||||
*/
|
||||
async function getAllTorrents() {
|
||||
const clients = getClients();
|
||||
if (clients.length === 0) {
|
||||
try {
|
||||
await initializeClients();
|
||||
const clients = getClientsByType('qbittorrent');
|
||||
|
||||
if (clients.length === 0) {
|
||||
logToFile('[qBittorrent] No instances configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
clients.map(client => client.getActiveDownloads().catch(err => {
|
||||
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
|
||||
return [];
|
||||
}))
|
||||
);
|
||||
|
||||
const allTorrents = results.flat();
|
||||
// Convert back to legacy format for backward compatibility
|
||||
const legacyTorrents = allTorrents.map(download => download.raw);
|
||||
|
||||
logToFile(`[qBittorrent] Total torrents from all instances: ${legacyTorrents.length}`);
|
||||
return legacyTorrents;
|
||||
} catch (error) {
|
||||
logToFile(`[qBittorrent] Error in getAllTorrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
clients.map(client => client.getTorrents().catch(err => {
|
||||
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
|
||||
return [];
|
||||
}))
|
||||
);
|
||||
|
||||
const allTorrents = results.flat();
|
||||
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
|
||||
return allTorrents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility
|
||||
*/
|
||||
function getClients() {
|
||||
logToFile('[qBittorrent] getClients() called - delegating to new system');
|
||||
return []; // Not used in new system
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
@@ -198,12 +118,14 @@ function mapTorrentToDownload(torrent) {
|
||||
hash: torrent.hash,
|
||||
category: torrent.category,
|
||||
tags: torrent.tags,
|
||||
savePath: torrent.content_path || torrent.save_path || null,
|
||||
addedOn: torrent.added_on || null,
|
||||
qbittorrent: true
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTorrents: getAllTorrents,
|
||||
getAllTorrents,
|
||||
getClients,
|
||||
mapTorrentToDownload,
|
||||
formatBytes,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
||||
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
||||
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
||||
// Redact everything after the colon to end-of-line (MediaBrowser headers span the full line)
|
||||
const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|authorization)\s*:[^\n]*/gi;
|
||||
// Bearer tokens
|
||||
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
// Basic auth credentials in URLs (http://user:pass@host)
|
||||
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
|
||||
// Redact only the host:port authority portion of URLs, preserving path/query so
|
||||
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
|
||||
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
|
||||
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
|
||||
|
||||
function sanitizeError(err) {
|
||||
let msg = (err && err.message) ? err.message : String(err);
|
||||
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
|
||||
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
|
||||
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
|
||||
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
|
||||
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
|
||||
// Never leak stack traces to API responses
|
||||
return msg;
|
||||
}
|
||||
|
||||
module.exports = sanitizeError;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Persistent token store backed by a JSON file.
|
||||
*
|
||||
* Pure JavaScript — no native addons, no build tools required.
|
||||
* Survives process restarts so users are not logged out on redeploy.
|
||||
*
|
||||
* Tokens are stored in DATA_DIR/tokens.json (default: ./data locally,
|
||||
* /app/data in the container). Writes are atomic: data is written to a
|
||||
* temp file then renamed so a crash mid-write never corrupts the store.
|
||||
*
|
||||
* Format: { "<userId>": { accessToken: "...", createdAt: <unix ms> } }
|
||||
*
|
||||
* Expired entries (older than TOKEN_TTL_DAYS) are pruned on startup
|
||||
* and once per hour.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const TOKEN_TTL_DAYS = 31; // slightly longer than max cookie lifetime (30d)
|
||||
const TOKEN_TTL_MS = TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const STORE_PATH = path.join(DATA_DIR, 'tokens.json');
|
||||
const STORE_TMP = STORE_PATH + '.tmp';
|
||||
|
||||
// Load store from disk, return empty object on any error
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic write: write to .tmp then rename to avoid partial-write corruption
|
||||
function save(data) {
|
||||
try {
|
||||
fs.writeFileSync(STORE_TMP, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(STORE_TMP, STORE_PATH);
|
||||
} catch (err) {
|
||||
console.error('[TokenStore] Failed to persist token store:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function prune(data) {
|
||||
const cutoff = Date.now() - TOKEN_TTL_MS;
|
||||
let pruned = 0;
|
||||
for (const userId of Object.keys(data)) {
|
||||
if (data[userId].createdAt < cutoff) {
|
||||
delete data[userId];
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
if (pruned > 0) {
|
||||
console.log(`[TokenStore] Pruned ${pruned} expired token(s)`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Prune on startup
|
||||
let store = prune(load());
|
||||
save(store);
|
||||
|
||||
// Prune once per hour (unref so it doesn't keep the process alive)
|
||||
setInterval(() => {
|
||||
store = prune(load());
|
||||
save(store);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
module.exports = {
|
||||
storeToken(userId, accessToken) {
|
||||
store[userId] = { accessToken, createdAt: Date.now() };
|
||||
save(store);
|
||||
},
|
||||
getToken(userId) {
|
||||
const entry = store[userId];
|
||||
if (!entry) return null;
|
||||
// Also honour TTL on read in case pruning hasn't run yet
|
||||
if (Date.now() - entry.createdAt > TOKEN_TTL_MS) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
return null;
|
||||
}
|
||||
return { accessToken: entry.accessToken };
|
||||
},
|
||||
clearToken(userId) {
|
||||
if (store[userId]) {
|
||||
delete store[userId];
|
||||
save(store);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
# Testing
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tool |
|
||||
|---|---|
|
||||
| Test runner | [Vitest](https://vitest.dev/) v4 |
|
||||
| HTTP integration | [supertest](https://github.com/ladjs/supertest) |
|
||||
| HTTP interception | [nock](https://github.com/nock/nock) (intercepts at Node http layer — works with CJS `require('axios')`) |
|
||||
| Coverage | V8 (built-in, no Babel needed) |
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (re-runs on file change)
|
||||
npm run test:watch
|
||||
|
||||
# With coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Interactive UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
Coverage output lands in `coverage/` (gitignored). Open `coverage/index.html` for the HTML report.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup.js # Global setup: isolated DATA_DIR, SKIP_RATE_LIMIT, console suppression
|
||||
├── unit/
|
||||
│ ├── sanitizeError.test.js # Secret redaction patterns (API keys, tokens, passwords)
|
||||
│ ├── config.test.js # JSON array + legacy single-instance config parsing
|
||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **`server/app.js`** — Express factory extracted from `server/index.js`. Tests import `createApp()` without triggering the log-file setup, `process.exit()` calls, or background poller in the entry point.
|
||||
- **nock over `vi.mock('axios')`** — Vitest's `vi.mock` only intercepts ESM `import` statements. Since `auth.js` uses CJS `require('axios')`, nock (which patches Node's `http`/`https` modules) is the correct tool for intercepting outbound requests.
|
||||
- **`SKIP_RATE_LIMIT=1`** — All supertest requests originate from `127.0.0.1`, which would quickly exhaust per-IP rate-limit windows. Setting this env var raises the limits to `Number.MAX_SAFE_INTEGER` in both the API limiter and the login limiter.
|
||||
- **Isolated `DATA_DIR`** — Each test worker gets a unique temp directory so `tokenStore.js` file I/O never conflicts with a running dev server.
|
||||
- **`createApp({ skipRateLimits: true })`** — The app factory accepts an option to disable the general API rate limiter in addition to the env var for the login-specific limiter.
|
||||
|
||||
## Coverage targets
|
||||
|
||||
The tested files meet these per-file minimums (enforced in CI):
|
||||
|
||||
| File | Lines | Branches |
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
|
||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for authentication routes.
|
||||
*
|
||||
* Uses supertest against the createApp() factory (no real server).
|
||||
* HTTP calls to Emby are intercepted at the Node http/https layer using nock,
|
||||
* which works correctly with CJS require('axios') unlike vi.mock which only
|
||||
* intercepts ESM imports.
|
||||
*
|
||||
* Covers:
|
||||
* - Input validation on /login (empty fields, overlong values)
|
||||
* - Successful login flow (cookies set, CSRF token returned)
|
||||
* - Failed login (wrong credentials → 401, no cookie set)
|
||||
* - /me endpoint (authenticated vs unauthenticated)
|
||||
* - /csrf token issuance
|
||||
* - /logout (cookies cleared)
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
// Emby response fixtures
|
||||
const EMBY_AUTH_BODY = {
|
||||
AccessToken: 'test-emby-token-abc123',
|
||||
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||
};
|
||||
|
||||
const EMBY_USER_BODY = {
|
||||
Id: 'user-id-001',
|
||||
Name: 'TestUser',
|
||||
Policy: { IsAdministrator: false }
|
||||
};
|
||||
|
||||
const EMBY_ADMIN_BODY = {
|
||||
Id: 'admin-id-001',
|
||||
Name: 'AdminUser',
|
||||
Policy: { IsAdministrator: true }
|
||||
};
|
||||
|
||||
// Helper: intercept a successful Emby login + user-info sequence
|
||||
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, EMBY_AUTH_BODY);
|
||||
nock(EMBY_BASE)
|
||||
.get(/\/Users\//)
|
||||
.reply(200, userBody);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll(); // remove any pending interceptors between tests
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
// Each sub-describe gets a fresh app to avoid rate-limit state leaking
|
||||
// between the 'input validation' calls (which all fail and count toward
|
||||
// the 10-failure window) and the 'successful login' calls.
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
// skipRateLimits avoids 429s from the login limiter when all
|
||||
// requests come from 127.0.0.1 in the test environment
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects empty username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: '', password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects username over 128 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'a'.repeat(129), password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects password over 256 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'p'.repeat(257) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-string username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 123, password: 'pass' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful login', () => {
|
||||
it('returns success:true with user info', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.user.name).toBe('TestUser');
|
||||
expect(res.body.user.isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('sets emby_user cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(true);
|
||||
});
|
||||
|
||||
it('sets csrf_token cookie', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns csrfToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('session cookie has no maxAge when rememberMe is false', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: false });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
// Session cookie must not persist across browser close
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).not.toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('sets 30-day maxAge when rememberMe is true', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct', rememberMe: true });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
const sessionCookie = cookies.find(c => c.startsWith('emby_user='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toContain('Max-Age');
|
||||
});
|
||||
|
||||
it('marks isAdmin correctly for admin user', async () => {
|
||||
interceptSuccessfulLogin(EMBY_ADMIN_BODY);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'AdminUser', password: 'correct' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('does not include AccessToken in response body', async () => {
|
||||
interceptSuccessfulLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'correct' });
|
||||
// The Emby access token must never be sent to the client
|
||||
expect(JSON.stringify(res.body)).not.toContain('test-emby-token-abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed login', () => {
|
||||
it('returns 401 when Emby rejects credentials', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, { error: 'Unauthorized' });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Must not expose internal error details
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
});
|
||||
|
||||
it('does not set emby_user cookie on failure', async () => {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(401, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'baduser', password: 'wrongpass' });
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('emby_user='))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
});
|
||||
|
||||
it('returns authenticated:false when no cookie', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('returns authenticated:true with valid cookie', async () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: false });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `emby_user=${encodeURIComponent(payload)}`);
|
||||
expect(res.body.authenticated).toBe(true);
|
||||
expect(res.body.user.name).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/csrf', () => {
|
||||
it('issues a csrf_token cookie and returns csrfToken in body', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/auth/csrf');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.csrfToken).toBe('string');
|
||||
expect(res.body.csrfToken.length).toBe(64); // 32 bytes hex
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.startsWith('csrf_token='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/logout', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
delete process.env.COOKIE_SECRET;
|
||||
app = createApp({ skipRateLimits: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
// NOTE: /api/auth/* is mounted BEFORE the verifyCsrf middleware in app.js,
|
||||
// so logout does not require a CSRF token by design. The session cookie's
|
||||
// sameSite:strict attribute provides equivalent CSRF protection for logout.
|
||||
it('succeeds without a CSRF token (sameSite:strict provides protection)', async () => {
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('clears cookies and returns success when CSRF token is provided', async () => {
|
||||
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||
const csrfToken = csrfRes.body.csrfToken;
|
||||
const csrfCookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||
|
||||
nock(EMBY_BASE).post('/Sessions/Logout').reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/logout')
|
||||
.set('Cookie', csrfCookie)
|
||||
.set('X-CSRF-Token', csrfToken);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Cookies should be cleared (Set-Cookie header with empty value / Max-Age=0)
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.some(c => c.includes('emby_user=;') || c.includes('Max-Age=0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import {
|
||||
initializeClients,
|
||||
getAllDownloads,
|
||||
getDownloadsByClientType,
|
||||
testAllConnections,
|
||||
registry
|
||||
} from '../../server/utils/downloadClients.js';
|
||||
import axios from 'axios';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock environment variables for testing
|
||||
process.env.SABNZBD_INSTANCES = JSON.stringify([
|
||||
{
|
||||
id: 'test-sab',
|
||||
name: 'Test SABnzbd',
|
||||
url: 'http://localhost:8080',
|
||||
apiKey: 'test-api-key'
|
||||
}
|
||||
]);
|
||||
|
||||
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
|
||||
{
|
||||
id: 'test-qb',
|
||||
name: 'Test qBittorrent',
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'adminadmin'
|
||||
}
|
||||
]);
|
||||
|
||||
process.env.TRANSMISSION_INSTANCES = JSON.stringify([
|
||||
{
|
||||
id: 'test-trans',
|
||||
name: 'Test Transmission',
|
||||
url: 'http://localhost:9091',
|
||||
username: 'transmission',
|
||||
password: 'transmission'
|
||||
}
|
||||
]);
|
||||
|
||||
process.env.RTORRENT_INSTANCES = JSON.stringify([
|
||||
{
|
||||
id: 'test-rtorrent',
|
||||
name: 'Test rTorrent',
|
||||
url: 'http://localhost:8080/RPC2',
|
||||
username: 'rtorrent',
|
||||
password: 'rtorrent'
|
||||
}
|
||||
]);
|
||||
|
||||
// Mock axios to prevent actual network calls
|
||||
vi.mock('axios', () => {
|
||||
const mockAxios = vi.fn();
|
||||
mockAxios.post = vi.fn();
|
||||
mockAxios.get = vi.fn();
|
||||
return {
|
||||
default: mockAxios,
|
||||
post: vi.fn(),
|
||||
get: vi.fn()
|
||||
};
|
||||
});
|
||||
vi.mock('../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
describe('Download Clients Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
registry.initialized = false;
|
||||
registry.clients.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Client Initialization', () => {
|
||||
it('should initialize all configured client types', async () => {
|
||||
await initializeClients();
|
||||
|
||||
// The registry should have clients for all three types
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
|
||||
// Should have keys for each client type (even if empty due to mocked failures)
|
||||
expect(typeof downloadsByType).toBe('object');
|
||||
});
|
||||
|
||||
it('should handle missing environment variables gracefully', async () => {
|
||||
// Temporarily clear environment variables
|
||||
const originalSab = process.env.SABNZBD_INSTANCES;
|
||||
const originalQb = process.env.QBITTORRENT_INSTANCES;
|
||||
const originalTrans = process.env.TRANSMISSION_INSTANCES;
|
||||
const originalRt = process.env.RTORRENT_INSTANCES;
|
||||
|
||||
delete process.env.SABNZBD_INSTANCES;
|
||||
delete process.env.QBITTORRENT_INSTANCES;
|
||||
delete process.env.TRANSMISSION_INSTANCES;
|
||||
delete process.env.RTORRENT_INSTANCES;
|
||||
|
||||
await initializeClients();
|
||||
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
expect(Object.keys(downloadsByType)).toHaveLength(0);
|
||||
|
||||
// Restore environment variables
|
||||
process.env.SABNZBD_INSTANCES = originalSab;
|
||||
process.env.QBITTORRENT_INSTANCES = originalQb;
|
||||
process.env.TRANSMISSION_INSTANCES = originalTrans;
|
||||
process.env.RTORRENT_INSTANCES = originalRt;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Aggregation', () => {
|
||||
it('should aggregate downloads from multiple client types', async () => {
|
||||
await initializeClients();
|
||||
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
const allDownloads = await getAllDownloads();
|
||||
|
||||
// Should return downloads grouped by type
|
||||
expect(typeof downloadsByType).toBe('object');
|
||||
|
||||
// Should return flattened array of all downloads
|
||||
expect(Array.isArray(allDownloads)).toBe(true);
|
||||
|
||||
// All downloads should have required normalized fields
|
||||
allDownloads.forEach(download => {
|
||||
expect(download).toHaveProperty('id');
|
||||
expect(download).toHaveProperty('title');
|
||||
expect(download).toHaveProperty('type');
|
||||
expect(download).toHaveProperty('client');
|
||||
expect(download).toHaveProperty('instanceId');
|
||||
expect(download).toHaveProperty('instanceName');
|
||||
expect(download).toHaveProperty('status');
|
||||
expect(download).toHaveProperty('progress');
|
||||
expect(download).toHaveProperty('size');
|
||||
expect(download).toHaveProperty('downloaded');
|
||||
expect(download).toHaveProperty('speed');
|
||||
expect(download).toHaveProperty('raw');
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain type consistency across clients', async () => {
|
||||
await initializeClients();
|
||||
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
|
||||
// Check that each client type returns consistent data structure
|
||||
Object.entries(downloadsByType).forEach(([clientType, downloads]) => {
|
||||
if (downloads.length > 0) {
|
||||
downloads.forEach(download => {
|
||||
expect(download.client).toBe(clientType);
|
||||
expect(download.type).toMatch(/^(usenet|torrent)$/);
|
||||
expect(typeof download.progress).toBe('number');
|
||||
expect(download.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(download.progress).toBeLessThanOrEqual(100);
|
||||
expect(typeof download.size).toBe('number');
|
||||
expect(typeof download.downloaded).toBe('number');
|
||||
expect(typeof download.speed).toBe('number');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Testing', () => {
|
||||
it('should test connections for all configured clients', async () => {
|
||||
await initializeClients();
|
||||
|
||||
const results = await testAllConnections();
|
||||
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
|
||||
results.forEach(result => {
|
||||
expect(result).toHaveProperty('instanceId');
|
||||
expect(result).toHaveProperty('instanceName');
|
||||
expect(result).toHaveProperty('clientType');
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle connection failures gracefully', async () => {
|
||||
// This test verifies that connection failures don't crash the system
|
||||
await initializeClients();
|
||||
|
||||
const results = await testAllConnections();
|
||||
|
||||
// Should still return results even if connections fail
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Resilience', () => {
|
||||
it('should handle individual client failures without affecting others', async () => {
|
||||
await initializeClients();
|
||||
|
||||
// Even if some clients fail, others should still work
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
const allDownloads = await getAllDownloads();
|
||||
|
||||
expect(typeof downloadsByType).toBe('object');
|
||||
expect(Array.isArray(allDownloads)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle malformed configuration gracefully', async () => {
|
||||
// Test with malformed JSON
|
||||
const originalSab = process.env.SABNZBD_INSTANCES;
|
||||
process.env.SABNZBD_INSTANCES = 'invalid-json{';
|
||||
|
||||
// Should not throw an error
|
||||
await expect(initializeClients()).resolves.not.toThrow();
|
||||
|
||||
// Restore
|
||||
process.env.SABNZBD_INSTANCES = originalSab;
|
||||
});
|
||||
|
||||
it('should handle network timeouts and errors', async () => {
|
||||
await initializeClients();
|
||||
|
||||
// Mock network failures by setting up axios to reject
|
||||
axios.get.mockRejectedValue(new Error('Network timeout'));
|
||||
axios.post.mockRejectedValue(new Error('Network timeout'));
|
||||
|
||||
// Should handle errors gracefully and return empty results
|
||||
const downloads = await getAllDownloads();
|
||||
expect(Array.isArray(downloads)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should maintain compatibility with existing cache structure', async () => {
|
||||
await initializeClients();
|
||||
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
|
||||
// SABnzbd downloads should have raw data for legacy compatibility
|
||||
if (downloadsByType.sabnzbd && downloadsByType.sabnzbd.length > 0) {
|
||||
downloadsByType.sabnzbd.forEach(download => {
|
||||
expect(download.raw).toBeTruthy();
|
||||
expect(download.raw.source).toMatch(/^(queue|history)$/);
|
||||
});
|
||||
}
|
||||
|
||||
// qBittorrent downloads should have raw data for legacy compatibility
|
||||
if (downloadsByType.qbittorrent && downloadsByType.qbittorrent.length > 0) {
|
||||
downloadsByType.qbittorrent.forEach(download => {
|
||||
expect(download.raw).toBeTruthy();
|
||||
expect(download.raw.hash).toBeTruthy(); // qBittorrent specific field
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and Scalability', () => {
|
||||
it('should handle multiple instances of the same client type', async () => {
|
||||
// Configure multiple instances
|
||||
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
|
||||
{
|
||||
id: 'test-qb-1',
|
||||
name: 'Test qBittorrent 1',
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'adminadmin'
|
||||
},
|
||||
{
|
||||
id: 'test-qb-2',
|
||||
name: 'Test qBittorrent 2',
|
||||
url: 'http://localhost:8081',
|
||||
username: 'admin',
|
||||
password: 'adminadmin'
|
||||
}
|
||||
]);
|
||||
|
||||
await initializeClients();
|
||||
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
|
||||
// Should aggregate downloads from both instances
|
||||
expect(Array.isArray(downloadsByType.qbittorrent)).toBe(true);
|
||||
|
||||
// Each download should have correct instance information
|
||||
downloadsByType.qbittorrent.forEach(download => {
|
||||
expect(download.instanceId).toMatch(/^(test-qb-1|test-qb-2)$/);
|
||||
expect(download.instanceName).toMatch(/^(Test qBittorrent 1|Test qBittorrent 2)$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute client requests in parallel', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await initializeClients();
|
||||
await getAllDownloads();
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// This is a rough check - in a real scenario with actual network calls,
|
||||
// parallel execution should be significantly faster than sequential
|
||||
expect(duration).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for health and readiness endpoints.
|
||||
*
|
||||
* /health and /ready are used by Docker HEALTHCHECK and must:
|
||||
* - Require no authentication
|
||||
* - Not be rate-limited
|
||||
* - Return the correct status codes
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
describe('GET /health', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('includes uptime as a number', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /ready', () => {
|
||||
let app;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
});
|
||||
|
||||
it('returns 200 when EMBY_URL is configured', async () => {
|
||||
process.env.EMBY_URL = 'https://emby.local';
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('returns 503 when EMBY_URL is not configured', async () => {
|
||||
delete process.env.EMBY_URL;
|
||||
app = createApp();
|
||||
const res = await request(app).get('/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not ready');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for GET /api/history/recent
|
||||
*
|
||||
* Uses supertest against createApp() with vi.mock to stub historyFetcher
|
||||
* (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth.
|
||||
* Covers:
|
||||
* - 401 when unauthenticated
|
||||
* - Empty history response when arr returns no records
|
||||
* - Filters out records whose eventType is not imported/failed
|
||||
* - Returns imported and failed records for tagged series/movies
|
||||
* - ?days= param is respected (default 7, capped at 90)
|
||||
* - failureMessage included for admins on failed records
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
// Use createRequire to get the same CJS singleton cache instance that
|
||||
// server/utils/historyFetcher.js and server/routes/history.js use via
|
||||
// require('./cache'). A plain ESM `import cache from '...'` resolves
|
||||
// to a different module identity under vitest's ESM runtime.
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
|
||||
// --- Fixtures ---
|
||||
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||
const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
|
||||
const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } };
|
||||
|
||||
// Sonarr tag: id 1 → 'alice'
|
||||
const SONARR_TAGS = [{ id: 1, label: 'alice' }];
|
||||
|
||||
const SONARR_RECORD_IMPORTED = {
|
||||
id: 100,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const SONARR_RECORD_FAILED = {
|
||||
id: 101,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S01E02.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Not enough disk space' },
|
||||
series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] },
|
||||
};
|
||||
|
||||
// Tag id 2 → 'admin' (used in the failed-import admin test)
|
||||
const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
|
||||
|
||||
const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test
|
||||
id: 103,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S01E02.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Disk full' },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const SONARR_RECORD_GRABBED = {
|
||||
id: 102,
|
||||
eventType: 'grabbed',
|
||||
sourceTitle: 'Show.S01E03.720p',
|
||||
date: new Date().toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const RADARR_RECORD_IMPORTED = {
|
||||
id: 200,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'My.Movie.2024.1080p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '1080p' } },
|
||||
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] },
|
||||
movieId: 20
|
||||
};
|
||||
|
||||
// Deduplication fixtures — same episodeId 55, episode 1 failed then imported
|
||||
const SONARR_RECORD_FAILED_EP55 = {
|
||||
id: 110,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S02E01.720p',
|
||||
date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Download failed' },
|
||||
episodeId: 55,
|
||||
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
const SONARR_RECORD_IMPORTED_EP55 = {
|
||||
id: 111,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S02E01.720p',
|
||||
date: new Date().toISOString(), // now (more recent)
|
||||
quality: { quality: { name: '720p' } },
|
||||
episodeId: 55,
|
||||
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
// Failed, still failing (hasFile=false) — most recent is a failure with no file
|
||||
const SONARR_RECORD_FAILED_EP56 = {
|
||||
id: 112,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S02E02.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'No seeders' },
|
||||
episodeId: 56,
|
||||
episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
// Failed but hasFile=true — episode is available, failure is an upgrade attempt
|
||||
const SONARR_RECORD_FAILED_EP57_HAS_FILE = {
|
||||
id: 113,
|
||||
eventType: 'downloadFailed',
|
||||
sourceTitle: 'Show.S02E03.720p',
|
||||
date: new Date().toISOString(),
|
||||
quality: { quality: { name: '720p' } },
|
||||
data: { message: 'Upgrade failed' },
|
||||
episodeId: 57,
|
||||
episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
|
||||
}
|
||||
|
||||
// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check
|
||||
// first, so they return without making any HTTP calls.
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run
|
||||
|
||||
function setHistory(sonarrRecords = [], radarrRecords = []) {
|
||||
cache.set('history:sonarr', sonarrRecords.map(r => ({
|
||||
...r,
|
||||
_instanceName: 'Main Sonarr',
|
||||
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
||||
})), CACHE_TTL);
|
||||
cache.set('history:radarr', radarrRecords.map(r => ({
|
||||
...r,
|
||||
_instanceName: 'Main Radarr',
|
||||
movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined
|
||||
})), CACHE_TTL);
|
||||
}
|
||||
|
||||
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
interceptLogin(userBody, authBody);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: userBody.Name, password: 'pw' });
|
||||
const cookies = res.headers['set-cookie'];
|
||||
const csrf = res.body.csrfToken;
|
||||
return { cookies, csrf };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear history caches so each test controls its own data
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
// Default: empty history
|
||||
setHistory([], []);
|
||||
// Seed poll tag caches so the route can resolve tags
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000);
|
||||
cache.set('poll:radarr-tags', [], 60000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.invalidate('history:radarr');
|
||||
});
|
||||
|
||||
describe('GET /api/history/recent', () => {
|
||||
describe('authentication', () => {
|
||||
it('returns 401 when not logged in', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/history/recent');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty history', () => {
|
||||
it('returns empty array when arr returns no records', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toEqual([]);
|
||||
expect(res.body.days).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event type filtering', () => {
|
||||
it('includes imported and failed records, excludes grabbed', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory(
|
||||
[SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED],
|
||||
[]
|
||||
);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const outcomes = res.body.history.map(h => h.outcome);
|
||||
expect(outcomes).toContain('imported');
|
||||
expect(outcomes).toContain('failed');
|
||||
expect(res.body.history).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag filtering', () => {
|
||||
it('only returns records tagged for the current user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
expect(res.body.history[0].seriesName).toBe('My Show');
|
||||
expect(res.body.history[0].outcome).toBe('imported');
|
||||
expect(res.body.history[0].quality).toBe('720p');
|
||||
});
|
||||
|
||||
it('excludes records tagged for a different user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } };
|
||||
const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } };
|
||||
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||
const { cookies } = await loginAs(app, bobUser, bobAuth);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radarr records', () => {
|
||||
it('returns movie history items', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
||||
setHistory([], [RADARR_RECORD_IMPORTED]);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
expect(res.body.history[0].type).toBe('movie');
|
||||
expect(res.body.history[0].movieName).toBe('My Movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('?days parameter', () => {
|
||||
it('uses custom days value', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent?days=14')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBe(14);
|
||||
});
|
||||
|
||||
it('caps days at 90', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent?days=999')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBe(7); // falls back to default when > 90
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed import details', () => {
|
||||
it('includes failureMessage for admin on failed records', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
||||
setHistory([SONARR_RECORD_FAILED], []);
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const failed = res.body.history.find(h => h.outcome === 'failed');
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed.failureMessage).toBe('Not enough disk space');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplication', () => {
|
||||
it('suppresses a failed record when the same episode was subsequently imported', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// API returns newest-first: imported (now) before failed (1hr ago)
|
||||
setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01'));
|
||||
expect(ep55Items).toHaveLength(1);
|
||||
expect(ep55Items[0].outcome).toBe('imported');
|
||||
});
|
||||
|
||||
it('shows a failed record as-is when there is no successful import and hasFile is false', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_FAILED_EP56], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const item = res.body.history.find(h => h.title && h.title.includes('S02E02'));
|
||||
expect(item).toBeDefined();
|
||||
expect(item.outcome).toBe('failed');
|
||||
expect(item.availableForUpgrade).toBeFalsy();
|
||||
});
|
||||
|
||||
it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const item = res.body.history.find(h => h.title && h.title.includes('S02E03'));
|
||||
expect(item).toBeDefined();
|
||||
expect(item.outcome).toBe('failed');
|
||||
expect(item.availableForUpgrade).toBe(true);
|
||||
});
|
||||
|
||||
it('does not expose _contentId in the response', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_IMPORTED_EP55], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
for (const item of res.body.history) {
|
||||
expect(item).not.toHaveProperty('_contentId');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('response shape', () => {
|
||||
it('returns correct top-level fields', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('user');
|
||||
expect(res.body).toHaveProperty('isAdmin');
|
||||
expect(res.body).toHaveProperty('days');
|
||||
expect(res.body).toHaveProperty('history');
|
||||
expect(Array.isArray(res.body.history)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Give each test worker a unique temp DATA_DIR so tokenStore file I/O is
|
||||
// fully isolated and doesn't conflict with a running dev server's data/.
|
||||
const tmpDir = path.join(os.tmpdir(), `sofarr-test-${process.pid}`);
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
// Disable rate limiters in tests — all supertest requests share 127.0.0.1
|
||||
// and would quickly exhaust per-IP windows otherwise.
|
||||
process.env.SKIP_RATE_LIMIT = '1';
|
||||
|
||||
// Suppress console noise during tests (errors still surface via thrown exceptions)
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// clearAllMocks resets call history and queued return values without
|
||||
// restoring mock implementations — use restoreAllMocks only for spies.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const DownloadClient = require('../../../server/clients/DownloadClient');
|
||||
|
||||
describe('DownloadClient', () => {
|
||||
describe('Abstract Base Class', () => {
|
||||
it('should throw error when instantiated directly', () => {
|
||||
expect(() => {
|
||||
new DownloadClient({ id: 'test', name: 'Test', url: 'http://test.com' });
|
||||
}).toThrow('DownloadClient is an abstract class and cannot be instantiated directly');
|
||||
});
|
||||
|
||||
it('should enforce implementation of required methods', () => {
|
||||
class TestClient extends DownloadClient {
|
||||
getClientType() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
|
||||
|
||||
expect(() => client.testConnection()).rejects.toThrow('testConnection() must be implemented by subclass');
|
||||
expect(() => client.getActiveDownloads()).rejects.toThrow('getActiveDownloads() must be implemented by subclass');
|
||||
expect(() => client.normalizeDownload({})).toThrow('normalizeDownload() must be implemented by subclass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Base Properties', () => {
|
||||
class TestClient extends DownloadClient {
|
||||
getClientType() {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
return [];
|
||||
}
|
||||
|
||||
normalizeDownload(download) {
|
||||
return download;
|
||||
}
|
||||
}
|
||||
|
||||
it('should set basic properties from config', () => {
|
||||
const config = {
|
||||
id: 'test-instance',
|
||||
name: 'Test Instance',
|
||||
url: 'http://test.com',
|
||||
apiKey: 'test-key',
|
||||
username: 'test-user',
|
||||
password: 'test-pass'
|
||||
};
|
||||
|
||||
const client = new TestClient(config);
|
||||
|
||||
expect(client.id).toBe('test-instance');
|
||||
expect(client.name).toBe('Test Instance');
|
||||
expect(client.url).toBe('http://test.com');
|
||||
expect(client.apiKey).toBe('test-key');
|
||||
expect(client.username).toBe('test-user');
|
||||
expect(client.password).toBe('test-pass');
|
||||
});
|
||||
|
||||
it('should return correct instance ID', () => {
|
||||
const client = new TestClient({ id: 'test-id', name: 'Test', url: 'http://test.com' });
|
||||
expect(client.getInstanceId()).toBe('test-id');
|
||||
});
|
||||
|
||||
it('should have optional getClientStatus method returning null', async () => {
|
||||
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
|
||||
const status = await client.getClientStatus();
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import QBittorrentClient from '../../../server/clients/QBittorrentClient.js';
|
||||
import nock from 'nock';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
describe('QBittorrentClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
id: 'test-qb',
|
||||
name: 'Test qBittorrent',
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'adminadmin'
|
||||
};
|
||||
|
||||
client = new QBittorrentClient(mockConfig);
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('qbittorrent');
|
||||
expect(client.getInstanceId()).toBe('test-qb');
|
||||
expect(client.name).toBe('Test qBittorrent');
|
||||
expect(client.url).toBe('http://localhost:8080');
|
||||
expect(client.authCookie).toBeNull();
|
||||
expect(client.lastRid).toBe(0);
|
||||
expect(client.torrentMap).toBeInstanceOf(Map);
|
||||
expect(client.fallbackThisCycle).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should login successfully with valid credentials', async () => {
|
||||
nock('http://localhost:8080')
|
||||
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
|
||||
.reply(200, {}, { 'set-cookie': ['SID=test-cookie'] });
|
||||
|
||||
const result = await client.login();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.authCookie).toBe('SID=test-cookie');
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
nock('http://localhost:8080')
|
||||
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
|
||||
.reply(200, {}, {});
|
||||
|
||||
const result = await client.login();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(client.authCookie).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
nock('http://localhost:8080')
|
||||
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
|
||||
.replyWithError(new Error('Network error'));
|
||||
|
||||
const result = await client.login();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(client.authCookie).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
// Mock login success
|
||||
client.login = vi.fn().mockResolvedValue(true);
|
||||
|
||||
// Mock version request
|
||||
const mockResponse = { data: 'v4.3.5' };
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('/api/v2/app/version');
|
||||
});
|
||||
|
||||
it('should handle connection test failure', async () => {
|
||||
client.login = vi.fn().mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Normalization', () => {
|
||||
it('should normalize torrent data correctly', () => {
|
||||
const torrent = {
|
||||
hash: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
state: 'downloading',
|
||||
progress: 0.75,
|
||||
size: 1000000000,
|
||||
completed: 750000000,
|
||||
dlspeed: 1048576,
|
||||
eta: 3600,
|
||||
category: 'movies',
|
||||
tags: 'movie,hd',
|
||||
content_path: '/downloads/test',
|
||||
added_on: 1640995200
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'abc123',
|
||||
title: 'Test Torrent',
|
||||
type: 'torrent',
|
||||
client: 'qbittorrent',
|
||||
instanceId: 'test-qb',
|
||||
instanceName: 'Test qBittorrent',
|
||||
status: 'Downloading',
|
||||
progress: 75,
|
||||
size: 1000000000,
|
||||
downloaded: 750000000,
|
||||
speed: 1048576,
|
||||
eta: 3600,
|
||||
category: 'movies',
|
||||
tags: ['movie', 'hd'],
|
||||
savePath: '/downloads/test',
|
||||
addedOn: '2022-01-01T00:00:00.000Z',
|
||||
raw: torrent
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown torrent states', () => {
|
||||
const torrent = {
|
||||
hash: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
state: 'unknown_state',
|
||||
progress: 0.5,
|
||||
size: 1000000,
|
||||
completed: 500000,
|
||||
dlspeed: 0,
|
||||
eta: -1
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.status).toBe('unknown_state');
|
||||
expect(normalized.eta).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing completed field', () => {
|
||||
const torrent = {
|
||||
hash: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
state: 'downloading',
|
||||
progress: 0.5,
|
||||
size: 1000000,
|
||||
dlspeed: 0
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.downloaded).toBe(500000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Flag Management', () => {
|
||||
it('should reset fallback flag', () => {
|
||||
client.fallbackThisCycle = true;
|
||||
client.resetFallbackFlag();
|
||||
expect(client.fallbackThisCycle).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle makeRequest authentication failure', async () => {
|
||||
client.authCookie = 'invalid-cookie';
|
||||
|
||||
// First request fails with 403
|
||||
nock('http://localhost:8080')
|
||||
.get('/test')
|
||||
.reply(403, {});
|
||||
|
||||
// Re-authentication succeeds
|
||||
nock('http://localhost:8080')
|
||||
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
|
||||
.reply(200, {}, { 'set-cookie': ['SID=new-cookie'] });
|
||||
|
||||
// Retry succeeds
|
||||
nock('http://localhost:8080')
|
||||
.get('/test')
|
||||
.reply(200, { data: 'success' });
|
||||
|
||||
const result = await client.makeRequest('/test');
|
||||
|
||||
expect(result.data).toEqual({ data: 'success' });
|
||||
expect(client.authCookie).toBe('SID=new-cookie');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import RTorrentClient from '../../../server/clients/RTorrentClient.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
describe('RTorrentClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
let mockMethodCall;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMethodCall = vi.fn();
|
||||
|
||||
mockConfig = {
|
||||
id: 'test-rtorrent',
|
||||
name: 'Test rTorrent',
|
||||
url: 'http://localhost:8080',
|
||||
username: 'rtorrent',
|
||||
password: 'rtorrent'
|
||||
};
|
||||
|
||||
client = new RTorrentClient(mockConfig);
|
||||
// Mock the xmlrpc client's methodCall directly
|
||||
client.client.methodCall = mockMethodCall;
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('rtorrent');
|
||||
expect(client.getInstanceId()).toBe('test-rtorrent');
|
||||
expect(client.name).toBe('Test rTorrent');
|
||||
expect(client.url).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('should create xmlrpc client with correct URL', async () => {
|
||||
expect(client.url).toBe('http://localhost:8080');
|
||||
expect(client.client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create xmlrpc client without auth when no credentials', () => {
|
||||
const noAuthConfig = {
|
||||
id: 'test-rtorrent-noauth',
|
||||
name: 'Test rTorrent No Auth',
|
||||
url: 'http://localhost:8080/RPC2'
|
||||
};
|
||||
const clientNoAuth = new RTorrentClient(noAuthConfig);
|
||||
expect(clientNoAuth.client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => {
|
||||
const whatboxConfig = {
|
||||
id: 'test-whatbox',
|
||||
name: 'Whatbox',
|
||||
url: 'https://user.whatbox.ca/xmlrpc',
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
};
|
||||
const clientWhatbox = new RTorrentClient(whatboxConfig);
|
||||
expect(clientWhatbox.client).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom RPC path exactly as configured', () => {
|
||||
const customConfig = {
|
||||
id: 'test-custom',
|
||||
name: 'Custom',
|
||||
url: 'https://example.com/custom/rpc/path'
|
||||
};
|
||||
const clientCustom = new RTorrentClient(customConfig);
|
||||
expect(clientCustom.client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(null, '0.9.8');
|
||||
});
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle connection test failure', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(new Error('Connection refused'));
|
||||
});
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveDownloads', () => {
|
||||
it('should fetch and normalize torrents', async () => {
|
||||
const mockTorrents = [
|
||||
[
|
||||
'abc123def456',
|
||||
'Test Torrent 1',
|
||||
1000000000,
|
||||
750000000,
|
||||
1048576,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads/test',
|
||||
'movies'
|
||||
],
|
||||
[
|
||||
'def789abc012',
|
||||
'Test Torrent 2',
|
||||
2000000000,
|
||||
2000000000,
|
||||
0,
|
||||
512000,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads/complete',
|
||||
'tv'
|
||||
]
|
||||
];
|
||||
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(null, mockTorrents);
|
||||
});
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toHaveLength(2);
|
||||
expect(downloads[0].id).toBe('abc123def456');
|
||||
expect(downloads[0].title).toBe('Test Torrent 1');
|
||||
expect(downloads[0].status).toBe('Downloading');
|
||||
expect(downloads[0].progress).toBe(75);
|
||||
expect(downloads[0].category).toBe('movies');
|
||||
expect(downloads[1].status).toBe('Seeding');
|
||||
expect(downloads[1].category).toBe('tv');
|
||||
});
|
||||
|
||||
it('should handle empty torrent list', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(null, []);
|
||||
});
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle XML-RPC errors gracefully', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(new Error('XML-RPC fault'));
|
||||
});
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDownload', () => {
|
||||
it('should normalize a downloading torrent', () => {
|
||||
const torrent = [
|
||||
'hash123',
|
||||
'Downloading Torrent',
|
||||
1000000000,
|
||||
500000000,
|
||||
1048576,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'hash123',
|
||||
title: 'Downloading Torrent',
|
||||
type: 'torrent',
|
||||
client: 'rtorrent',
|
||||
instanceId: 'test-rtorrent',
|
||||
instanceName: 'Test rTorrent',
|
||||
status: 'Downloading',
|
||||
progress: 50,
|
||||
size: 1000000000,
|
||||
downloaded: 500000000,
|
||||
speed: 1048576,
|
||||
eta: 477,
|
||||
category: undefined,
|
||||
tags: [],
|
||||
savePath: '/downloads',
|
||||
addedOn: undefined,
|
||||
arrQueueId: undefined,
|
||||
arrType: undefined,
|
||||
raw: torrent
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize a seeding torrent', () => {
|
||||
const torrent = [
|
||||
'hash456',
|
||||
'Seeding Torrent',
|
||||
500000000,
|
||||
500000000,
|
||||
0,
|
||||
204800,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads/complete',
|
||||
'movies'
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.status).toBe('Seeding');
|
||||
expect(normalized.progress).toBe(100);
|
||||
expect(normalized.speed).toBe(204800);
|
||||
expect(normalized.eta).toBeNull();
|
||||
expect(normalized.category).toBe('movies');
|
||||
expect(normalized.tags).toEqual(['movies']);
|
||||
});
|
||||
|
||||
it('should normalize a paused torrent', () => {
|
||||
const torrent = [
|
||||
'hash789',
|
||||
'Paused Torrent',
|
||||
1000000000,
|
||||
250000000,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.status).toBe('Paused');
|
||||
expect(normalized.speed).toBe(0);
|
||||
expect(normalized.eta).toBeNull();
|
||||
});
|
||||
|
||||
it('should normalize a stopped torrent', () => {
|
||||
const torrent = [
|
||||
'hashabc',
|
||||
'Stopped Torrent',
|
||||
1000000000,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.status).toBe('Stopped');
|
||||
});
|
||||
|
||||
it('should normalize a checking torrent', () => {
|
||||
const torrent = [
|
||||
'hashdef',
|
||||
'Checking Torrent',
|
||||
1000000000,
|
||||
500000000,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.status).toBe('Checking');
|
||||
});
|
||||
|
||||
it('should handle zero-size torrent', () => {
|
||||
const torrent = [
|
||||
'hash000',
|
||||
'Zero Size',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized.progress).toBe(0);
|
||||
expect(normalized.size).toBe(0);
|
||||
expect(normalized.downloaded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Mapping', () => {
|
||||
const testCases = [
|
||||
{ state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' },
|
||||
{ state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' },
|
||||
{ state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' },
|
||||
{ state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' },
|
||||
{ state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' },
|
||||
{ state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' },
|
||||
{ state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => {
|
||||
it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => {
|
||||
const status = client._mapStatus(state, isActive, isHashChecking, completed, size);
|
||||
expect(status).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARR Info Extraction', () => {
|
||||
it('should extract series info from filename', () => {
|
||||
const torrent = [
|
||||
'hash123',
|
||||
'Show Name - S01E02 - Episode Title',
|
||||
1000000000,
|
||||
500000000,
|
||||
1048576,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBe('series');
|
||||
});
|
||||
|
||||
it('should extract movie info from filename', () => {
|
||||
const torrent = [
|
||||
'hash456',
|
||||
'Movie Title (2023) 1080p',
|
||||
2000000000,
|
||||
1000000000,
|
||||
1048576,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBe('movie');
|
||||
});
|
||||
|
||||
it('should not extract ARR info from generic filename', () => {
|
||||
const torrent = [
|
||||
'hash789',
|
||||
'Generic File Name.mkv',
|
||||
1000000000,
|
||||
500000000,
|
||||
1048576,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
'/downloads',
|
||||
''
|
||||
];
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Status', () => {
|
||||
it('should get client status', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
if (method === 'throttle.global_down.rate') {
|
||||
callback(null, 1048576);
|
||||
} else if (method === 'throttle.global_up.rate') {
|
||||
callback(null, 512000);
|
||||
}
|
||||
});
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
globalDownRate: 1048576,
|
||||
globalUpRate: 512000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle status request errors', async () => {
|
||||
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||
callback(new Error('Status error'));
|
||||
});
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import SABnzbdClient from '../../../server/clients/SABnzbdClient.js';
|
||||
import nock from 'nock';
|
||||
import { vi } from 'vitest';
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
describe('SABnzbdClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
id: 'test-sab',
|
||||
name: 'Test SABnzbd',
|
||||
url: 'http://localhost:8080',
|
||||
apiKey: 'test-api-key'
|
||||
};
|
||||
|
||||
client = new SABnzbdClient(mockConfig);
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('sabnzbd');
|
||||
expect(client.getInstanceId()).toBe('test-sab');
|
||||
expect(client.name).toBe('Test SABnzbd');
|
||||
expect(client.url).toBe('http://localhost:8080');
|
||||
expect(client.apiKey).toBe('test-api-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
const mockResponse = {
|
||||
data: { version: '3.6.1' }
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('', { mode: 'version' });
|
||||
});
|
||||
|
||||
it('should handle connection test failure', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Requests', () => {
|
||||
it('should make API request with correct parameters', async () => {
|
||||
nock('http://localhost:8080')
|
||||
.get('/api')
|
||||
.query({
|
||||
output: 'json',
|
||||
apikey: 'test-api-key',
|
||||
mode: 'queue',
|
||||
limit: 10
|
||||
})
|
||||
.reply(200, { result: 'success' });
|
||||
|
||||
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
|
||||
|
||||
expect(result.data).toEqual({ result: 'success' });
|
||||
});
|
||||
|
||||
it('should handle API request errors', async () => {
|
||||
nock('http://localhost:8080')
|
||||
.get('/api')
|
||||
.query({ output: 'json', apikey: 'test-api-key', mode: 'queue' })
|
||||
.replyWithError(new Error('API Error'));
|
||||
|
||||
await expect(client.makeRequest({ mode: 'queue' })).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Normalization', () => {
|
||||
it('should normalize queue download correctly', () => {
|
||||
const slot = {
|
||||
nzo_id: 'test123',
|
||||
filename: 'Test Movie.mkv',
|
||||
status: 'Downloading',
|
||||
progress: 75.5,
|
||||
mb: 1000,
|
||||
mbleft: 245,
|
||||
kbpersec: 1024,
|
||||
timeleft: '0:15:30',
|
||||
cat: 'movies',
|
||||
labels: 'movie,hd',
|
||||
added: 1640995200
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(slot, 'queue');
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'test123',
|
||||
title: 'Test Movie.mkv',
|
||||
type: 'usenet',
|
||||
client: 'sabnzbd',
|
||||
instanceId: 'test-sab',
|
||||
instanceName: 'Test SABnzbd',
|
||||
status: 'Downloading',
|
||||
progress: 76,
|
||||
size: 1000 * 1024 * 1024,
|
||||
downloaded: 755 * 1024 * 1024,
|
||||
speed: 1024 * 1024,
|
||||
eta: 930, // 15 minutes 30 seconds
|
||||
category: 'movies',
|
||||
tags: ['movie', 'hd'],
|
||||
savePath: undefined,
|
||||
addedOn: '2022-01-01T00:00:00.000Z',
|
||||
raw: { ...slot, source: 'queue' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize history download correctly', () => {
|
||||
const slot = {
|
||||
nzo_id: 'test456',
|
||||
filename: 'Test Series S01E01.mkv',
|
||||
status: 'Completed',
|
||||
mb: 500,
|
||||
mbleft: 0,
|
||||
cat: 'tv',
|
||||
added: 1640995200
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(slot, 'history');
|
||||
|
||||
expect(normalized.status).toBe('Completed');
|
||||
expect(normalized.progress).toBe(100);
|
||||
expect(normalized.downloaded).toBe(500 * 1024 * 1024);
|
||||
expect(normalized.speed).toBe(0);
|
||||
expect(normalized.eta).toBeNull();
|
||||
expect(normalized.raw.source).toBe('history');
|
||||
});
|
||||
|
||||
it('should parse time strings correctly', () => {
|
||||
const testCases = [
|
||||
{ input: '0:05:30', expected: 330 }, // 5m 30s
|
||||
{ input: '15:30', expected: 930 }, // 15m 30s
|
||||
{ input: '330', expected: 330 }, // 330 seconds
|
||||
{ input: 'unknown', expected: null }, // unknown
|
||||
{ input: '0:00', expected: null } // zero
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const slot = {
|
||||
nzo_id: 'test',
|
||||
filename: 'Test',
|
||||
status: 'Downloading',
|
||||
progress: 50,
|
||||
mb: 1000,
|
||||
mbleft: 500,
|
||||
timeleft: input
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(slot, 'queue');
|
||||
expect(normalized.eta).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract Sonarr/Radarr info from filename', () => {
|
||||
const testCases = [
|
||||
{ filename: 'Show Name - S01E02 - Episode Title', expectedType: 'series' },
|
||||
{ filename: 'Movie Title (2023) 1080p', expectedType: 'movie' },
|
||||
{ filename: 'Random File Name.mkv', expectedType: undefined }
|
||||
];
|
||||
|
||||
testCases.forEach(({ filename, expectedType }) => {
|
||||
const slot = {
|
||||
nzo_id: 'test',
|
||||
filename: filename,
|
||||
status: 'Downloading',
|
||||
progress: 50,
|
||||
mb: 1000,
|
||||
mbleft: 500
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(slot, 'queue');
|
||||
expect(normalized.arrType).toBe(expectedType);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle size parsing from strings', () => {
|
||||
const slot = {
|
||||
nzo_id: 'test',
|
||||
filename: 'Test',
|
||||
status: 'Downloading',
|
||||
progress: 50,
|
||||
size: '1.5 GB',
|
||||
sizeleft: '0.75 GB'
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(slot, 'queue');
|
||||
|
||||
expect(normalized.size).toBe(1.5 * 1024 * 1024 * 1024);
|
||||
expect(normalized.downloaded).toBe(0.75 * 1024 * 1024 * 1024);
|
||||
expect(normalized.progress).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit Multipliers', () => {
|
||||
it('should return correct multipliers for different units', () => {
|
||||
expect(client.getUnitMultiplier('b')).toBe(1);
|
||||
expect(client.getUnitMultiplier('KB')).toBe(1024);
|
||||
expect(client.getUnitMultiplier('mb')).toBe(1024 * 1024);
|
||||
expect(client.getUnitMultiplier('GB')).toBe(1024 * 1024 * 1024);
|
||||
expect(client.getUnitMultiplier('tb')).toBe(1024 * 1024 * 1024 * 1024);
|
||||
expect(client.getUnitMultiplier('unknown')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Downloads', () => {
|
||||
it('should fetch and normalize downloads from queue and history', async () => {
|
||||
const mockQueueResponse = {
|
||||
data: {
|
||||
queue: {
|
||||
slots: [
|
||||
{ nzo_id: 'queue1', filename: 'Queue Item', status: 'Downloading' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockHistoryResponse = {
|
||||
data: {
|
||||
history: {
|
||||
slots: [
|
||||
{ nzo_id: 'hist1', filename: 'History Item', status: 'Completed' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn()
|
||||
.mockResolvedValueOnce(mockQueueResponse)
|
||||
.mockResolvedValueOnce(mockHistoryResponse);
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'queue' });
|
||||
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 10 });
|
||||
expect(downloads).toHaveLength(2);
|
||||
expect(downloads[0].id).toBe('queue1');
|
||||
expect(downloads[1].id).toBe('hist1');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Status', () => {
|
||||
it('should get client status', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Active',
|
||||
speed: 1048576,
|
||||
kbpersec: 1024,
|
||||
sizeleft: 500000000,
|
||||
mbleft: 500
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(status).toEqual({
|
||||
status: 'Active',
|
||||
speed: 1048576,
|
||||
kbpersec: 1024,
|
||||
sizeleft: 500000000,
|
||||
mbleft: 500
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle status request errors', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import TransmissionClient from '../../../server/clients/TransmissionClient.js';
|
||||
import nock from 'nock';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
describe('TransmissionClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
id: 'test-transmission',
|
||||
name: 'Test Transmission',
|
||||
url: 'http://localhost:9091',
|
||||
username: 'transmission',
|
||||
password: 'transmission'
|
||||
};
|
||||
|
||||
client = new TransmissionClient(mockConfig);
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('transmission');
|
||||
expect(client.getInstanceId()).toBe('test-transmission');
|
||||
expect(client.name).toBe('Test Transmission');
|
||||
expect(client.url).toBe('http://localhost:9091');
|
||||
expect(client.sessionId).toBeNull();
|
||||
expect(client.rpcUrl).toBe('http://localhost:9091/transmission/rpc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
const mockResponse = {
|
||||
data: { result: 'success', arguments: {} }
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
|
||||
});
|
||||
|
||||
it('should handle connection test failure', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RPC Requests', () => {
|
||||
it('should make RPC request with session ID', async () => {
|
||||
client.sessionId = 'test-session-id';
|
||||
|
||||
nock('http://localhost:9091')
|
||||
.post('/transmission/rpc', {
|
||||
method: 'torrent-get',
|
||||
arguments: { fields: ['id', 'name'] }
|
||||
})
|
||||
.reply(200, { result: 'success', arguments: { torrents: [] } });
|
||||
|
||||
const result = await client.makeRequest('torrent-get', { fields: ['id', 'name'] });
|
||||
|
||||
expect(result.data).toEqual({ result: 'success', arguments: { torrents: [] } });
|
||||
});
|
||||
|
||||
it('should handle session ID conflict (409)', async () => {
|
||||
nock('http://localhost:9091')
|
||||
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
|
||||
.reply(409, {}, { 'x-transmission-session-id': 'new-session-id' });
|
||||
|
||||
nock('http://localhost:9091')
|
||||
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
|
||||
.reply(200, { result: 'success', arguments: {} });
|
||||
|
||||
const result = await client.makeRequest('session-get');
|
||||
|
||||
expect(client.sessionId).toBe('new-session-id');
|
||||
expect(result.data).toEqual({ result: 'success', arguments: {} });
|
||||
});
|
||||
|
||||
it('should handle RPC errors', async () => {
|
||||
nock('http://localhost:9091')
|
||||
.post('/transmission/rpc', { method: 'invalid-method', arguments: {} })
|
||||
.reply(200, { result: 'error', 'error-message': 'Invalid request' });
|
||||
|
||||
await expect(client.makeRequest('invalid-method')).rejects.toThrow('Transmission RPC error: error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Normalization', () => {
|
||||
it('should normalize torrent data correctly', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
status: 4, // downloading
|
||||
totalSize: 1000000000,
|
||||
sizeWhenDone: 1000000000,
|
||||
leftUntilDone: 250000000,
|
||||
rateDownload: 1048576,
|
||||
rateUpload: 0,
|
||||
eta: 3600,
|
||||
downloadedEver: 750000000,
|
||||
uploadedEver: 0,
|
||||
percentDone: 0.75,
|
||||
addedDate: 1640995200,
|
||||
doneDate: 0,
|
||||
labels: ['movies', 'hd'],
|
||||
downloadDir: '/downloads/test'
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'abc123',
|
||||
title: 'Test Torrent',
|
||||
type: 'torrent',
|
||||
client: 'transmission',
|
||||
instanceId: 'test-transmission',
|
||||
instanceName: 'Test Transmission',
|
||||
status: 'Downloading',
|
||||
progress: 75,
|
||||
size: 1000000000,
|
||||
downloaded: 750000000,
|
||||
speed: 1048576,
|
||||
eta: 3600,
|
||||
category: 'movies',
|
||||
tags: ['movies', 'hd'],
|
||||
savePath: '/downloads/test',
|
||||
addedOn: '2022-01-01T00:00:00.000Z',
|
||||
raw: torrent
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different torrent statuses', () => {
|
||||
const statusMap = {
|
||||
0: 'Stopped',
|
||||
1: 'Queued',
|
||||
2: 'Checking',
|
||||
3: 'Queued',
|
||||
4: 'Downloading',
|
||||
5: 'Queued',
|
||||
6: 'Seeding',
|
||||
7: 'Unknown'
|
||||
};
|
||||
|
||||
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test',
|
||||
status: parseInt(status),
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.status).toBe(expectedStatus);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown ETA values', () => {
|
||||
const testCases = [
|
||||
{ eta: -1, expected: null },
|
||||
{ eta: -2, expected: null },
|
||||
{ eta: 3600, expected: 3600 }
|
||||
];
|
||||
|
||||
testCases.forEach(({ eta, expected }) => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: eta,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.eta).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract category from first label', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: ['movies', 'hd', '4k']
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.category).toBe('movies');
|
||||
expect(normalized.tags).toEqual(['movies', 'hd', '4k']);
|
||||
});
|
||||
|
||||
it('should handle empty labels', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.category).toBeUndefined();
|
||||
expect(normalized.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Downloads', () => {
|
||||
it('should fetch and normalize torrents', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
torrents: [
|
||||
{
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Test Torrent 1',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 1048576,
|
||||
eta: 3600,
|
||||
percentDone: 0.5,
|
||||
labels: ['test']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hashString: 'def456',
|
||||
name: 'Test Torrent 2',
|
||||
status: 6,
|
||||
totalSize: 2000000,
|
||||
sizeWhenDone: 2000000,
|
||||
leftUntilDone: 0,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 1.0,
|
||||
labels: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('torrent-get', {
|
||||
fields: [
|
||||
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
|
||||
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
|
||||
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
|
||||
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
|
||||
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
|
||||
]
|
||||
});
|
||||
|
||||
expect(downloads).toHaveLength(2);
|
||||
expect(downloads[0].title).toBe('Test Torrent 1');
|
||||
expect(downloads[0].status).toBe('Downloading');
|
||||
expect(downloads[1].title).toBe('Test Torrent 2');
|
||||
expect(downloads[1].status).toBe('Seeding');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty torrent list', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: { torrents: [] }
|
||||
}
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
|
||||
|
||||
const downloads = await client.getActiveDownloads();
|
||||
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Status', () => {
|
||||
it('should get client status and session stats', async () => {
|
||||
const mockSessionResponse = {
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'download-dir': '/downloads',
|
||||
'peer-port': 51413,
|
||||
'rpc-version': 15
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockStatsResponse = {
|
||||
data: {
|
||||
result: 'success',
|
||||
arguments: {
|
||||
'downloaded-bytes': 1000000000,
|
||||
'uploaded-bytes': 500000000,
|
||||
'torrent-count': 5
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.makeRequest = vi.fn()
|
||||
.mockResolvedValueOnce(mockSessionResponse)
|
||||
.mockResolvedValueOnce(mockStatsResponse);
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('session-stats');
|
||||
expect(status).toEqual({
|
||||
session: mockSessionResponse.data.arguments,
|
||||
stats: mockStatsResponse.data.arguments
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle status request errors', async () => {
|
||||
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
|
||||
|
||||
const status = await client.getClientStatus();
|
||||
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARR Info Extraction', () => {
|
||||
it('should extract series info from filename', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Show Name - S01E02 - Episode Title',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBe('series');
|
||||
});
|
||||
|
||||
it('should extract movie info from filename', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Movie Title (2023) 1080p',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBe('movie');
|
||||
});
|
||||
|
||||
it('should not extract ARR info from generic filename', () => {
|
||||
const torrent = {
|
||||
id: 1,
|
||||
hashString: 'abc123',
|
||||
name: 'Generic File Name.mkv',
|
||||
status: 4,
|
||||
totalSize: 1000000,
|
||||
sizeWhenDone: 1000000,
|
||||
leftUntilDone: 500000,
|
||||
rateDownload: 0,
|
||||
eta: -1,
|
||||
percentDone: 0.5,
|
||||
labels: []
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
expect(normalized.arrType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/utils/config.js
|
||||
*
|
||||
* Verifies that instance config is parsed correctly from both the modern JSON
|
||||
* array format and the legacy single-instance env var format. This is critical
|
||||
* because misconfigured instances silently return no data rather than crashing.
|
||||
*/
|
||||
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||
|
||||
describe('parseInstances', () => {
|
||||
describe('JSON array format', () => {
|
||||
it('parses a valid single-instance JSON array', () => {
|
||||
const json = JSON.stringify([{ name: 'main', url: 'https://sonarr.local', apiKey: 'abc123' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('abc123');
|
||||
});
|
||||
|
||||
it('parses multiple instances', () => {
|
||||
const json = JSON.stringify([
|
||||
{ name: 'main', url: 'https://s1.local', apiKey: 'key1' },
|
||||
{ name: 'backup', url: 'https://s2.local', apiKey: 'key2' }
|
||||
]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].name).toBe('backup');
|
||||
});
|
||||
|
||||
it('adds id from name when present', () => {
|
||||
const json = JSON.stringify([{ name: 'i3omb', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('generates fallback id when name is absent', () => {
|
||||
const json = JSON.stringify([{ url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result[0].id).toBe('instance-1');
|
||||
});
|
||||
|
||||
it('handles multi-line JSON by stripping whitespace', () => {
|
||||
const json = `[
|
||||
{
|
||||
"name": "main",
|
||||
"url": "https://sonarr.local",
|
||||
"apiKey": "abc"
|
||||
}
|
||||
]`;
|
||||
const result = parseInstances(json, null, null);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array for empty JSON array', () => {
|
||||
expect(parseInstances('[]', null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy format when JSON is malformed', () => {
|
||||
const result = parseInstances('not-json', 'https://legacy.local', 'legacyKey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://legacy.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy single-instance format', () => {
|
||||
it('returns single instance from legacy URL + key', () => {
|
||||
const result = parseInstances(null, 'https://sonarr.local', 'legacyapikey');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('default');
|
||||
expect(result[0].name).toBe('Default');
|
||||
expect(result[0].url).toBe('https://sonarr.local');
|
||||
expect(result[0].apiKey).toBe('legacyapikey');
|
||||
});
|
||||
|
||||
it('returns empty array for qBittorrent with no apiKey and no JSON (legacy requires key)', () => {
|
||||
// parseInstances requires legacyKey to be truthy for the legacy path;
|
||||
// qBittorrent uses JSON array format, not the legacy URL+key path.
|
||||
const result = parseInstances(null, 'https://qbt.local', null, 'admin', 'pass123');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when both JSON and legacy URL are missing', () => {
|
||||
expect(parseInstances(null, null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when URL is set but key is missing', () => {
|
||||
expect(parseInstances(null, 'https://sonarr.local', null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-based getters', () => {
|
||||
it('getSonarrInstances reads SONARR_INSTANCES from env', () => {
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([{ name: 'test', url: 'https://s.local', apiKey: 'k' }]);
|
||||
const result = getSonarrInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('test');
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
});
|
||||
|
||||
it('getRadarrInstances returns empty array when unconfigured', () => {
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
const result = getRadarrInstances();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import {
|
||||
DownloadClientRegistry,
|
||||
registry,
|
||||
initializeClients,
|
||||
getAllClients,
|
||||
getClient,
|
||||
getClientsByType,
|
||||
getAllDownloads,
|
||||
getDownloadsByClientType,
|
||||
testAllConnections,
|
||||
getAllClientStatuses
|
||||
} from '../../server/utils/downloadClients.js';
|
||||
import * as mockConfig from '../../server/utils/config.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock config and clients
|
||||
vi.mock('../../server/utils/config', () => ({
|
||||
getSABnzbdInstances: vi.fn(),
|
||||
getQbittorrentInstances: vi.fn(),
|
||||
getTransmissionInstances: vi.fn(),
|
||||
getRtorrentInstances: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../server/clients/SABnzbdClient', () => {
|
||||
return vi.fn().mockImplementation((config) => ({
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => config.id,
|
||||
name: config.name,
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
|
||||
]),
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
|
||||
}));
|
||||
});
|
||||
|
||||
vi.mock('../../server/clients/QBittorrentClient', () => {
|
||||
return vi.fn().mockImplementation((config) => ({
|
||||
getClientType: () => 'qbittorrent',
|
||||
getInstanceId: () => config.id,
|
||||
name: config.name,
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
|
||||
]),
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' }),
|
||||
resetFallbackFlag: vi.fn()
|
||||
}));
|
||||
});
|
||||
|
||||
vi.mock('../../server/clients/TransmissionClient', () => {
|
||||
return vi.fn().mockImplementation((config) => ({
|
||||
getClientType: () => 'transmission',
|
||||
getInstanceId: () => config.id,
|
||||
name: config.name,
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'trans1', title: 'Trans Download 1', client: 'transmission' }
|
||||
]),
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
|
||||
}));
|
||||
});
|
||||
|
||||
vi.mock('../../server/clients/RTorrentClient', () => {
|
||||
return vi.fn().mockImplementation((config) => ({
|
||||
getClientType: () => 'rtorrent',
|
||||
getInstanceId: () => config.id,
|
||||
name: config.name,
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'rt1', title: 'rTorrent Download 1', client: 'rtorrent' }
|
||||
]),
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
|
||||
}));
|
||||
});
|
||||
|
||||
describe('DownloadClientRegistry', () => {
|
||||
let testRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
testRegistry = new DownloadClientRegistry();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize all configured client types', async () => {
|
||||
// Manually add mock clients to the registry
|
||||
const mockSabClient = {
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => 'sab1',
|
||||
name: 'SAB 1'
|
||||
};
|
||||
const mockQbClient = {
|
||||
getClientType: () => 'qbittorrent',
|
||||
getInstanceId: () => 'qb1',
|
||||
name: 'QB 1'
|
||||
};
|
||||
const mockTransClient = {
|
||||
getClientType: () => 'transmission',
|
||||
getInstanceId: () => 'trans1',
|
||||
name: 'Trans 1'
|
||||
};
|
||||
testRegistry.clients.set('sab1', mockSabClient);
|
||||
testRegistry.clients.set('qb1', mockQbClient);
|
||||
testRegistry.clients.set('trans1', mockTransClient);
|
||||
|
||||
expect(testRegistry.getAllClients()).toHaveLength(3);
|
||||
expect(testRegistry.getClient('sab1')).toBeTruthy();
|
||||
expect(testRegistry.getClient('qb1')).toBeTruthy();
|
||||
expect(testRegistry.getClient('trans1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle empty config', async () => {
|
||||
// Registry is already empty from beforeEach
|
||||
expect(testRegistry.getAllClients()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not initialize twice', async () => {
|
||||
// Manually set initialized flag to true
|
||||
testRegistry.initialized = true;
|
||||
|
||||
// Try to initialize again
|
||||
await testRegistry.initialize();
|
||||
|
||||
// Config should not be called since initialized is true
|
||||
expect(mockConfig.getSABnzbdInstances).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle client creation errors gracefully', async () => {
|
||||
// Registry is already empty from beforeEach
|
||||
expect(testRegistry.getAllClients()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Manually add mock client to the registry
|
||||
const mockSabClient = {
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => 'sab1',
|
||||
name: 'SAB 1',
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([])
|
||||
};
|
||||
testRegistry.clients.set('sab1', mockSabClient);
|
||||
});
|
||||
|
||||
it('should get all clients', () => {
|
||||
const clients = testRegistry.getAllClients();
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0].getClientType()).toBe('sabnzbd');
|
||||
});
|
||||
|
||||
it('should get client by ID', () => {
|
||||
const client = testRegistry.getClient('sab1');
|
||||
expect(client).toBeTruthy();
|
||||
expect(client.getInstanceId()).toBe('sab1');
|
||||
});
|
||||
|
||||
it('should return null for non-existent client', () => {
|
||||
const client = testRegistry.getClient('nonexistent');
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
|
||||
it('should get clients by type', () => {
|
||||
const sabClients = testRegistry.getClientsByType('sabnzbd');
|
||||
expect(sabClients).toHaveLength(1);
|
||||
|
||||
const qbClients = testRegistry.getClientsByType('qbittorrent');
|
||||
expect(qbClients).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Manually add mock clients to the registry
|
||||
const mockSabClient = {
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => 'sab1',
|
||||
name: 'SAB 1',
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
|
||||
])
|
||||
};
|
||||
const mockQbClient = {
|
||||
getClientType: () => 'qbittorrent',
|
||||
getInstanceId: () => 'qb1',
|
||||
name: 'QB 1',
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([
|
||||
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
|
||||
]),
|
||||
resetFallbackFlag: vi.fn()
|
||||
};
|
||||
testRegistry.clients.set('sab1', mockSabClient);
|
||||
testRegistry.clients.set('qb1', mockQbClient);
|
||||
});
|
||||
|
||||
it('should get all downloads from all clients', async () => {
|
||||
const downloads = await testRegistry.getAllDownloads();
|
||||
|
||||
expect(downloads).toHaveLength(2);
|
||||
expect(downloads[0].client).toBe('sabnzbd');
|
||||
expect(downloads[1].client).toBe('qbittorrent');
|
||||
});
|
||||
|
||||
it('should reset fallback flags for qBittorrent clients', async () => {
|
||||
const qbClient = testRegistry.getClient('qb1');
|
||||
|
||||
await testRegistry.getAllDownloads();
|
||||
|
||||
expect(qbClient.resetFallbackFlag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get downloads grouped by client type', async () => {
|
||||
const downloadsByType = await testRegistry.getDownloadsByClientType();
|
||||
|
||||
expect(downloadsByType.sabnzbd).toHaveLength(1);
|
||||
expect(downloadsByType.qbittorrent).toHaveLength(1);
|
||||
expect(downloadsByType.transmission).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle client errors gracefully', async () => {
|
||||
const sabClient = testRegistry.getClient('sab1');
|
||||
sabClient.getActiveDownloads.mockRejectedValue(new Error('Client error'));
|
||||
|
||||
const downloads = await testRegistry.getAllDownloads();
|
||||
|
||||
expect(downloads).toHaveLength(1); // Only qBittorrent succeeds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Testing', () => {
|
||||
beforeEach(async () => {
|
||||
// Manually add mock clients to the registry
|
||||
const mockSabClient = {
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => 'sab1',
|
||||
name: 'SAB 1',
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([])
|
||||
};
|
||||
const mockQbClient = {
|
||||
getClientType: () => 'qbittorrent',
|
||||
getInstanceId: () => 'qb1',
|
||||
name: 'QB 1',
|
||||
testConnection: vi.fn().mockResolvedValue(true),
|
||||
getActiveDownloads: vi.fn().mockResolvedValue([])
|
||||
};
|
||||
testRegistry.clients.set('sab1', mockSabClient);
|
||||
testRegistry.clients.set('qb1', mockQbClient);
|
||||
});
|
||||
|
||||
it('should test all connections', async () => {
|
||||
const results = await testRegistry.testAllConnections();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({
|
||||
instanceId: 'sab1',
|
||||
instanceName: 'SAB 1',
|
||||
clientType: 'sabnzbd',
|
||||
success: true,
|
||||
error: null
|
||||
});
|
||||
expect(results[1]).toEqual({
|
||||
instanceId: 'qb1',
|
||||
instanceName: 'QB 1',
|
||||
clientType: 'qbittorrent',
|
||||
success: true,
|
||||
error: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle connection test failures', async () => {
|
||||
const sabClient = testRegistry.getClient('sab1');
|
||||
sabClient.testConnection.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const results = await testRegistry.testAllConnections();
|
||||
|
||||
expect(results[0].success).toBe(false);
|
||||
expect(results[0].error).toBe('Connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Status', () => {
|
||||
beforeEach(async () => {
|
||||
// Manually add a mock client to the registry
|
||||
const mockClient = {
|
||||
getClientType: () => 'sabnzbd',
|
||||
getInstanceId: () => 'sab1',
|
||||
name: 'SAB 1',
|
||||
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
|
||||
};
|
||||
testRegistry.clients.set('sab1', mockClient);
|
||||
});
|
||||
|
||||
it('should get all client statuses', async () => {
|
||||
const statuses = await testRegistry.getAllClientStatuses();
|
||||
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]).toEqual({
|
||||
instanceId: 'sab1',
|
||||
instanceName: 'SAB 1',
|
||||
clientType: 'sabnzbd',
|
||||
status: { status: 'active' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle status request errors', async () => {
|
||||
const sabClient = testRegistry.getClient('sab1');
|
||||
sabClient.getClientStatus.mockRejectedValue(new Error('Status error'));
|
||||
|
||||
const statuses = await testRegistry.getAllClientStatuses();
|
||||
|
||||
expect(statuses[0].status).toBeNull();
|
||||
expect(statuses[0].error).toBe('Status error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Convenience Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should delegate to singleton registry', async () => {
|
||||
mockConfig.getSABnzbdInstances.mockReturnValue([]);
|
||||
mockConfig.getQbittorrentInstances.mockReturnValue([]);
|
||||
mockConfig.getTransmissionInstances.mockReturnValue([]);
|
||||
|
||||
await initializeClients();
|
||||
|
||||
expect(getAllClients()).toBeInstanceOf(Array);
|
||||
expect(getClient('test')).toBeNull();
|
||||
expect(getClientsByType('sabnzbd')).toBeInstanceOf(Array);
|
||||
expect(await getAllDownloads()).toBeInstanceOf(Array);
|
||||
expect(await getDownloadsByClientType()).toBeInstanceOf(Object);
|
||||
expect(await testAllConnections()).toBeInstanceOf(Array);
|
||||
expect(await getAllClientStatuses()).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Unit tests for server/utils/historyFetcher.js
|
||||
*
|
||||
* Covers:
|
||||
* - classifySonarrEvent / classifyRadarrEvent event classification
|
||||
* - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors
|
||||
* - invalidateHistoryCache
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Env must be set before importing modules that read it at load time
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
|
||||
]);
|
||||
|
||||
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
|
||||
await import('../../server/utils/historyFetcher.js');
|
||||
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
invalidateHistoryCache();
|
||||
});
|
||||
|
||||
describe('classifySonarrEvent', () => {
|
||||
it('returns imported for downloadFolderImported', () => {
|
||||
expect(classifySonarrEvent('downloadFolderImported')).toBe('imported');
|
||||
});
|
||||
it('returns imported for downloadImported', () => {
|
||||
expect(classifySonarrEvent('downloadImported')).toBe('imported');
|
||||
});
|
||||
it('returns failed for downloadFailed', () => {
|
||||
expect(classifySonarrEvent('downloadFailed')).toBe('failed');
|
||||
});
|
||||
it('returns failed for importFailed', () => {
|
||||
expect(classifySonarrEvent('importFailed')).toBe('failed');
|
||||
});
|
||||
it('returns other for grabbed', () => {
|
||||
expect(classifySonarrEvent('grabbed')).toBe('other');
|
||||
});
|
||||
it('returns other for unknown event', () => {
|
||||
expect(classifySonarrEvent('someFutureEvent')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRadarrEvent', () => {
|
||||
it('returns imported for downloadFolderImported', () => {
|
||||
expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported');
|
||||
});
|
||||
it('returns failed for downloadFailed', () => {
|
||||
expect(classifyRadarrEvent('downloadFailed')).toBe('failed');
|
||||
});
|
||||
it('returns other for grabbed', () => {
|
||||
expect(classifyRadarrEvent('grabbed')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSonarrHistory', () => {
|
||||
const mockRecords = [
|
||||
{
|
||||
id: 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01',
|
||||
date: new Date().toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||
seriesId: 10
|
||||
}
|
||||
];
|
||||
|
||||
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].series._instanceUrl).toBe('https://sonarr.test');
|
||||
expect(result[0].series._instanceName).toBe('Main Sonarr');
|
||||
expect(result[0]._instanceName).toBe('Main Sonarr');
|
||||
});
|
||||
|
||||
it('returns cached data on second call without making a new HTTP request', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const first = await fetchSonarrHistory(since);
|
||||
// Second call — nock would throw if a second request was made
|
||||
const second = await fetchSonarrHistory(since);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('returns empty array and does not throw when instance errors', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.replyWithError('ECONNREFUSED');
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles missing records key gracefully', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, {});
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRadarrHistory', () => {
|
||||
const mockRecords = [
|
||||
{
|
||||
id: 2,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'My.Movie.2024',
|
||||
date: new Date().toISOString(),
|
||||
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] },
|
||||
movieId: 20
|
||||
}
|
||||
];
|
||||
|
||||
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||
nock('https://radarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchRadarrHistory(since);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].movie._instanceUrl).toBe('https://radarr.test');
|
||||
expect(result[0].movie._instanceName).toBe('Main Radarr');
|
||||
});
|
||||
|
||||
it('returns empty array on network error', async () => {
|
||||
nock('https://radarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.replyWithError('timeout');
|
||||
|
||||
const result = await fetchRadarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateHistoryCache', () => {
|
||||
it('forces a fresh fetch after invalidation', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
await fetchSonarrHistory(since);
|
||||
invalidateHistoryCache();
|
||||
|
||||
// Should make a second HTTP request — nock will satisfy it
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||
*
|
||||
* mapTorrentToDownload, formatBytes, formatSpeed, and formatEta are all
|
||||
* pure functions with no I/O — ideal unit test targets. These power the
|
||||
* dashboard card rendering so correctness matters for UX.
|
||||
*/
|
||||
|
||||
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
|
||||
import QBittorrentClient from '../../server/clients/QBittorrentClient.js';
|
||||
import nock from 'nock';
|
||||
|
||||
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
|
||||
function makeTorrent(overrides = {}) {
|
||||
return {
|
||||
name: 'My.Show.S01E01.1080p.mkv',
|
||||
state: 'downloading',
|
||||
size: 1073741824, // 1 GB
|
||||
completed: 536870912, // 512 MB
|
||||
progress: 0.5,
|
||||
dlspeed: 1048576, // 1 MB/s
|
||||
eta: 512, // seconds
|
||||
num_seeds: 10,
|
||||
num_leechs: 3,
|
||||
availability: 1.0,
|
||||
hash: 'aabbccdd',
|
||||
category: 'sonarr',
|
||||
tags: '',
|
||||
content_path: '/downloads/My.Show.S01E01.1080p.mkv',
|
||||
save_path: '/downloads/',
|
||||
instanceName: 'i3omb',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const QBT_URL = 'http://qbittorrent.test:8080';
|
||||
|
||||
function makeClient(overrides = {}) {
|
||||
return new QBittorrentClient({
|
||||
id: 'test-qbt',
|
||||
name: 'TestQBT',
|
||||
url: QBT_URL,
|
||||
username: 'admin',
|
||||
password: 'adminadmin',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
function mockLogin() {
|
||||
return nock(QBT_URL)
|
||||
.post('/api/v2/auth/login')
|
||||
.reply(200, {}, { 'set-cookie': ['SID=abc123; path=/'] });
|
||||
}
|
||||
|
||||
function mockSync(rid, response) {
|
||||
return nock(QBT_URL)
|
||||
.get(`/api/v2/sync/maindata?rid=${rid}`)
|
||||
.reply(200, response);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
|
||||
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||
it('formats kilobytes', () => expect(formatBytes(1024)).toBe('1 KB'));
|
||||
it('formats megabytes', () => expect(formatBytes(1048576)).toBe('1 MB'));
|
||||
it('formats gigabytes', () => expect(formatBytes(1073741824)).toBe('1 GB'));
|
||||
it('formats fractional GB', () => expect(formatBytes(1610612736)).toBe('1.5 GB'));
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('appends /s to byte count', () => expect(formatSpeed(1048576)).toBe('1 MB/s'));
|
||||
it('handles zero speed', () => expect(formatSpeed(0)).toBe('0 B/s'));
|
||||
});
|
||||
|
||||
describe('formatEta', () => {
|
||||
it('returns ∞ for qBittorrent unknown sentinel (8640000)', () => {
|
||||
expect(formatEta(8640000)).toBe('∞');
|
||||
});
|
||||
it('returns ∞ for negative eta', () => expect(formatEta(-1)).toBe('∞'));
|
||||
it('formats minutes only', () => expect(formatEta(90)).toBe('1m'));
|
||||
it('formats hours and minutes', () => expect(formatEta(3661)).toBe('1h 1m'));
|
||||
it('formats days, hours and minutes', () => expect(formatEta(90061)).toBe('1d 1h 1m'));
|
||||
it('returns 0m for zero seconds', () => expect(formatEta(0)).toBe('0m'));
|
||||
});
|
||||
|
||||
describe('mapTorrentToDownload', () => {
|
||||
it('maps a downloading torrent correctly', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent());
|
||||
expect(result.status).toBe('Downloading');
|
||||
expect(result.progress).toBe('50.0');
|
||||
expect(result.size).toBe('1 GB');
|
||||
expect(result.speed).toBe('1 MB/s');
|
||||
expect(result.eta).toBe('8m');
|
||||
expect(result.seeds).toBe(10);
|
||||
expect(result.peers).toBe(3);
|
||||
expect(result.qbittorrent).toBe(true);
|
||||
expect(result.instanceName).toBe('i3omb');
|
||||
});
|
||||
|
||||
it('maps state: stalledDL → Downloading', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stalledDL' })).status).toBe('Downloading');
|
||||
});
|
||||
|
||||
it('maps state: uploading → Seeding', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'uploading' })).status).toBe('Seeding');
|
||||
});
|
||||
|
||||
it('maps state: pausedDL → Paused', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'pausedDL' })).status).toBe('Paused');
|
||||
});
|
||||
|
||||
it('maps state: stoppedUP → Stopped', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'stoppedUP' })).status).toBe('Stopped');
|
||||
});
|
||||
|
||||
it('maps state: error → Error', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'error' })).status).toBe('Error');
|
||||
});
|
||||
|
||||
it('passes through unknown state verbatim', () => {
|
||||
expect(mapTorrentToDownload(makeTorrent({ state: 'weirdState' })).status).toBe('weirdState');
|
||||
});
|
||||
|
||||
it('computes 100% progress for completed torrent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ progress: 1.0 }));
|
||||
expect(result.progress).toBe('100.0');
|
||||
});
|
||||
|
||||
it('uses content_path as savePath when present', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: '/dl/file.mkv' }));
|
||||
expect(result.savePath).toBe('/dl/file.mkv');
|
||||
});
|
||||
|
||||
it('falls back to save_path when content_path is absent', () => {
|
||||
const result = mapTorrentToDownload(makeTorrent({ content_path: null, save_path: '/dl/' }));
|
||||
expect(result.savePath).toBe('/dl/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QBittorrentClient sync API', () => {
|
||||
it('first call uses rid=0 and returns full torrent list', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].title).toBe('Test1');
|
||||
expect(torrents[0].instanceId).toBe('test-qbt');
|
||||
expect(torrents[0].id).toBe('hash01');
|
||||
expect(client.lastRid).toBe(1);
|
||||
});
|
||||
|
||||
it('subsequent call uses last rid and merges delta', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
// First call
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
await client.getActiveDownloads();
|
||||
|
||||
// Second call — delta
|
||||
mockSync(1, {
|
||||
rid: 2,
|
||||
full_update: false,
|
||||
torrents: {
|
||||
hash01: { dlspeed: 200 }
|
||||
}
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].speed).toBe(200);
|
||||
expect(torrents[0].title).toBe('Test1');
|
||||
expect(client.lastRid).toBe(2);
|
||||
});
|
||||
|
||||
it('handles full_update=true on subsequent call', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
// First call
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
await client.getActiveDownloads();
|
||||
|
||||
// Server forces full refresh
|
||||
mockSync(1, {
|
||||
rid: 2,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash02: { name: 'Test2', state: 'uploading', size: 2000, progress: 1.0, dlspeed: 0, eta: 0, num_seeds: 10, num_leechs: 0, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].title).toBe('Test2');
|
||||
expect(torrents[0].id).toBe('hash02');
|
||||
expect(client.lastRid).toBe(2);
|
||||
});
|
||||
|
||||
it('removes torrents when torrents_removed is present', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
await client.getActiveDownloads();
|
||||
|
||||
mockSync(1, {
|
||||
rid: 2,
|
||||
full_update: false,
|
||||
torrents_removed: ['hash01']
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to torrents/info when sync fails', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
// Sync fails with 500
|
||||
nock(QBT_URL)
|
||||
.get('/api/v2/sync/maindata?rid=0')
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
// Legacy succeeds
|
||||
nock(QBT_URL)
|
||||
.get('/api/v2/torrents/info')
|
||||
.reply(200, [
|
||||
{ name: 'Fallback', hash: 'fb01', state: 'downloading', size: 1073741824, progress: 0.5, dlspeed: 1048576, eta: 512, num_seeds: 10, num_leechs: 3, availability: 1.0 }
|
||||
]);
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].title).toBe('Fallback');
|
||||
expect(client.fallbackThisCycle).toBe(true);
|
||||
});
|
||||
|
||||
it('uses legacy immediately if already fell back this cycle', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
client.fallbackThisCycle = true;
|
||||
|
||||
// Only legacy should be called
|
||||
nock(QBT_URL)
|
||||
.get('/api/v2/torrents/info')
|
||||
.reply(200, [
|
||||
{ name: 'DirectLegacy', hash: 'dl01', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
]);
|
||||
|
||||
// Ensure sync is NOT called
|
||||
const syncScope = nock(QBT_URL)
|
||||
.get('/api/v2/sync/maindata?rid=0')
|
||||
.reply(200, { rid: 1, full_update: true });
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].title).toBe('DirectLegacy');
|
||||
expect(syncScope.isDone()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-authenticates on 403 during sync and retries', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
// First sync call returns 403
|
||||
nock(QBT_URL)
|
||||
.get('/api/v2/sync/maindata?rid=0')
|
||||
.reply(403, {});
|
||||
|
||||
// Re-login
|
||||
nock(QBT_URL)
|
||||
.post('/api/v2/auth/login')
|
||||
.reply(200, {}, { 'set-cookie': ['SID=newtoken; path=/'] });
|
||||
|
||||
// Retry succeeds
|
||||
nock(QBT_URL)
|
||||
.get('/api/v2/sync/maindata?rid=0')
|
||||
.reply(200, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'AfterReauth', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents).toHaveLength(1);
|
||||
expect(torrents[0].title).toBe('AfterReauth');
|
||||
});
|
||||
|
||||
it('computes completed from size and progress when missing', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'NoCompleted', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents[0].downloaded).toBe(500);
|
||||
});
|
||||
|
||||
it('resets fallback flag when getAllTorrents resets it', async () => {
|
||||
mockLogin();
|
||||
const client = makeClient();
|
||||
await client.login();
|
||||
client.fallbackThisCycle = true;
|
||||
|
||||
// After reset, sync should be attempted
|
||||
mockSync(0, {
|
||||
rid: 1,
|
||||
full_update: true,
|
||||
torrents: {
|
||||
hash01: { name: 'ResetWorks', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the reset that getAllTorrents performs
|
||||
client.fallbackThisCycle = false;
|
||||
const torrents = await client.getActiveDownloads();
|
||||
expect(torrents[0].title).toBe('ResetWorks');
|
||||
expect(client.fallbackThisCycle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/middleware/requireAuth.js
|
||||
*
|
||||
* requireAuth guards all authenticated API routes. Tests exercise the full
|
||||
* range of valid/invalid cookie states to ensure there's no bypass path.
|
||||
*/
|
||||
|
||||
import requireAuth from '../../server/middleware/requireAuth.js';
|
||||
|
||||
// Build mock req/res/next objects
|
||||
function makeReq({ signedCookie, plainCookie, cookieSecret } = {}) {
|
||||
// Set COOKIE_SECRET so signed path is taken when provided
|
||||
if (cookieSecret !== undefined) {
|
||||
process.env.COOKIE_SECRET = cookieSecret;
|
||||
} else {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
}
|
||||
|
||||
return {
|
||||
signedCookies: { emby_user: signedCookie },
|
||||
cookies: { emby_user: plainCookie }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.COOKIE_SECRET;
|
||||
});
|
||||
|
||||
describe('requireAuth middleware', () => {
|
||||
describe('valid sessions', () => {
|
||||
it('calls next() with a valid signed cookie', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
const req = makeReq({ signedCookie: payload, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user).toMatchObject({ id: 'u1', name: 'Alice', isAdmin: true });
|
||||
});
|
||||
|
||||
it('calls next() with a valid unsigned cookie (no COOKIE_SECRET)', () => {
|
||||
const payload = JSON.stringify({ id: 'u2', name: 'Bob', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user.id).toBe('u2');
|
||||
});
|
||||
|
||||
it('coerces non-boolean isAdmin to boolean', () => {
|
||||
const payload = JSON.stringify({ id: 'u3', name: 'Charlie', isAdmin: 1 });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing or invalid cookies', () => {
|
||||
it('returns 401 when no cookie is present', () => {
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when signed cookie value is false (tampered)', () => {
|
||||
// cookie-parser sets signed cookie to false when signature is invalid
|
||||
const req = makeReq({ signedCookie: false, cookieSecret: 'secret' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 for malformed JSON in cookie', () => {
|
||||
const req = makeReq({ plainCookie: 'not-json' });
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
requireAuth(req, res, next);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid session');
|
||||
});
|
||||
|
||||
it('returns 401 when id is missing', () => {
|
||||
const payload = JSON.stringify({ name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
requireAuth(req, makeRes(), vi.fn());
|
||||
// no next called — handled in the assertion below
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
requireAuth(req, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when name is missing', () => {
|
||||
const payload = JSON.stringify({ id: 'u1', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 when id is empty string', () => {
|
||||
const payload = JSON.stringify({ id: '', name: 'Alice', isAdmin: false });
|
||||
const req = makeReq({ plainCookie: payload });
|
||||
const res = makeRes();
|
||||
requireAuth(req, res, vi.fn());
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/utils/sanitizeError.js
|
||||
*
|
||||
* Critical security tests: verify that API keys, tokens, passwords and other
|
||||
* secrets are NEVER leaked in error messages returned to clients or written
|
||||
* to logs. Every pattern here represents a real credential type used in the
|
||||
* sofarr stack (SABnzbd apikey, Emby tokens, qBittorrent basic-auth URLs).
|
||||
*/
|
||||
|
||||
import sanitizeError from '../../server/utils/sanitizeError.js';
|
||||
|
||||
describe('sanitizeError', () => {
|
||||
describe('query-param secrets', () => {
|
||||
it('redacts ?apikey= values', () => {
|
||||
const err = new Error('Request failed: https://sabnzbd.local/api?apikey=abc123secret&output=json');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
expect(sanitizeError(err)).not.toContain('abc123secret');
|
||||
});
|
||||
|
||||
it('redacts &apikey= mid-URL', () => {
|
||||
const err = new Error('GET https://host/path?mode=queue&apikey=SUPERSECRET&output=json');
|
||||
expect(sanitizeError(err)).not.toContain('SUPERSECRET');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts ?token= values', () => {
|
||||
const err = new Error('https://api.example.com/data?token=tok_private99');
|
||||
expect(sanitizeError(err)).not.toContain('tok_private99');
|
||||
});
|
||||
|
||||
it('redacts ?password= values', () => {
|
||||
const err = new Error('Auth failed: https://service.local?password=hunter2');
|
||||
expect(sanitizeError(err)).not.toContain('hunter2');
|
||||
});
|
||||
|
||||
it('redacts ?api_key= values', () => {
|
||||
const err = new Error('https://sonarr.local/api/v3/series?api_key=e583d270f89846478e42');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42');
|
||||
});
|
||||
|
||||
it('preserves non-secret query params', () => {
|
||||
const result = sanitizeError(new Error('GET /api?mode=queue&output=json'));
|
||||
expect(result).toContain('mode=queue');
|
||||
expect(result).toContain('output=json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP auth headers', () => {
|
||||
it('redacts X-Api-Key header values', () => {
|
||||
const err = new Error('Request: x-api-key: e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).not.toContain('e583d270f89846478e42dd3cf90bfb00');
|
||||
expect(sanitizeError(err)).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts X-MediaBrowser-Token header values', () => {
|
||||
const err = new Error('x-mediabrowser-token: b862f3a43f4c417285043a11aa28b1f7');
|
||||
expect(sanitizeError(err)).not.toContain('b862f3a43f4c417285043a11aa28b1f7');
|
||||
});
|
||||
|
||||
it('redacts Authorization header values', () => {
|
||||
const err = new Error('authorization: MediaBrowser Token="abc123", DeviceId="xyz"');
|
||||
expect(sanitizeError(err)).not.toContain('abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer tokens', () => {
|
||||
it('redacts Bearer token values', () => {
|
||||
const err = new Error('Error: bearer eyJhbGciOiJIUzI1NiJ9.payload.sig');
|
||||
expect(sanitizeError(err)).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||
expect(sanitizeError(err)).toContain('bearer [REDACTED]');
|
||||
});
|
||||
|
||||
it('is case-insensitive for BEARER', () => {
|
||||
const err = new Error('BEARER TOKEN_VALUE_HERE');
|
||||
expect(sanitizeError(err)).not.toContain('TOKEN_VALUE_HERE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic-auth URLs', () => {
|
||||
it('redacts user:pass@ in URLs', () => {
|
||||
const err = new Error('GET http://admin:b053288369XX!@qbittorrent.local/api');
|
||||
expect(sanitizeError(err)).not.toContain('b053288369XX!');
|
||||
expect(sanitizeError(err)).not.toContain('admin:');
|
||||
expect(sanitizeError(err)).toContain('//[REDACTED]@');
|
||||
});
|
||||
|
||||
it('handles https:// basic auth', () => {
|
||||
const err = new Error('https://user:s3cr3t@service.local/path');
|
||||
expect(sanitizeError(err)).not.toContain('s3cr3t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles non-Error input (plain string)', () => {
|
||||
const result = sanitizeError('plain string error');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('handles null gracefully', () => {
|
||||
expect(() => sanitizeError(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles undefined gracefully', () => {
|
||||
expect(() => sanitizeError(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves non-sensitive error messages unchanged', () => {
|
||||
const err = new Error('Connection refused: ECONNREFUSED 127.0.0.1:8080');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).toContain('ECONNREFUSED');
|
||||
expect(result).toContain('127.0.0.1:8080');
|
||||
});
|
||||
|
||||
it('does not leak stack traces (returns message only)', () => {
|
||||
const err = new Error('something went wrong');
|
||||
const result = sanitizeError(err);
|
||||
expect(result).not.toContain('at ');
|
||||
expect(result).not.toContain('.js:');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/utils/tokenStore.js
|
||||
*
|
||||
* The token store persists Emby access tokens to disk (JSON file) so users
|
||||
* survive server restarts without re-logging in. Tests verify the store/get/
|
||||
* clear lifecycle, TTL expiry, and atomic write behaviour.
|
||||
*
|
||||
* Each test imports a FRESH module instance (vi.resetModules) so the
|
||||
* module-level singleton state (loaded from disk) doesn't bleed between tests.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Each test gets its own isolated temp dir
|
||||
let tmpDir;
|
||||
let tokenStore;
|
||||
|
||||
async function freshStore(dir) {
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dir;
|
||||
const mod = await import('../../server/utils/tokenStore.js');
|
||||
return mod;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sofarr-ts-'));
|
||||
tokenStore = await freshStore(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('tokenStore', () => {
|
||||
it('stores and retrieves a token', () => {
|
||||
tokenStore.storeToken('user1', 'access-token-abc');
|
||||
const result = tokenStore.getToken('user1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.accessToken).toBe('access-token-abc');
|
||||
});
|
||||
|
||||
it('returns null for an unknown user', () => {
|
||||
expect(tokenStore.getToken('nobody')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stored token', () => {
|
||||
tokenStore.storeToken('user1', 'token-xyz');
|
||||
tokenStore.clearToken('user1');
|
||||
expect(tokenStore.getToken('user1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearToken is a no-op for unknown user', () => {
|
||||
expect(() => tokenStore.clearToken('ghost')).not.toThrow();
|
||||
});
|
||||
|
||||
it('overwrites existing token on re-store', () => {
|
||||
tokenStore.storeToken('user1', 'old-token');
|
||||
tokenStore.storeToken('user1', 'new-token');
|
||||
expect(tokenStore.getToken('user1').accessToken).toBe('new-token');
|
||||
});
|
||||
|
||||
it('persists to disk (tokens.json exists after store)', () => {
|
||||
tokenStore.storeToken('u1', 'tok');
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
expect(fs.existsSync(storePath)).toBe(true);
|
||||
const data = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||
expect(data.u1.accessToken).toBe('tok');
|
||||
});
|
||||
|
||||
it('expires tokens older than 31 days on read', () => {
|
||||
// Write an already-expired entry directly to disk
|
||||
const expired = Date.now() - (32 * 24 * 60 * 60 * 1000);
|
||||
const storePath = path.join(tmpDir, 'tokens.json');
|
||||
fs.writeFileSync(storePath, JSON.stringify({ u1: { accessToken: 'old', createdAt: expired } }));
|
||||
// Re-import to load from disk
|
||||
vi.resetModules();
|
||||
return import('../../server/utils/tokenStore.js').then(mod => {
|
||||
expect(mod.getToken('u1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/middleware/verifyCsrf.js
|
||||
*
|
||||
* CSRF protection via the double-submit cookie pattern. These tests verify
|
||||
* that the timing-safe comparison works correctly and that safe HTTP methods
|
||||
* are correctly exempted.
|
||||
*/
|
||||
|
||||
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
||||
|
||||
function makeReq(method, cookieToken, headerToken) {
|
||||
return {
|
||||
method,
|
||||
cookies: { csrf_token: cookieToken },
|
||||
headers: { 'x-csrf-token': headerToken }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { this.body = body; return this; }
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('verifyCsrf middleware', () => {
|
||||
describe('safe methods are exempted', () => {
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
it(`allows ${method} with no CSRF token`, () => {
|
||||
const next = vi.fn();
|
||||
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('mutating methods require valid token', () => {
|
||||
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
||||
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
it(`allows ${method} with matching tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with mismatched tokens`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing cookie token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token missing');
|
||||
});
|
||||
|
||||
it(`blocks ${method} with missing header token`, () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
}
|
||||
|
||||
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body.error).toBe('CSRF token invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
|
||||
environment: 'node',
|
||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||
globals: true,
|
||||
// Run each test file in an isolated module registry so module-level state
|
||||
// (tokenStore cache, config singletons) doesn't leak between files
|
||||
isolate: true,
|
||||
// Give each file its own data directory so tokenStore file I/O doesn't collide
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
// Coverage via V8 (built into Node — no babel transform needed)
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov', 'html'],
|
||||
reportsDirectory: './coverage',
|
||||
// Only measure coverage on production source files
|
||||
include: ['server/**/*.js'],
|
||||
exclude: [
|
||||
'server/index.js', // entry point with side-effects (process.exit, log streams)
|
||||
'node_modules/**',
|
||||
'tests/**',
|
||||
'coverage/**'
|
||||
],
|
||||
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||
// coverage counting varies across Node versions (CI consistently reports
|
||||
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||
// The overall numbers reflect that dashboard.js and poller.js are large
|
||||
// untested files; the security-critical files (auth, middleware, utils)
|
||||
// are well-covered by the 115 tests.
|
||||
thresholds: {
|
||||
lines: 22,
|
||||
functions: 12,
|
||||
branches: 8,
|
||||
statements: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user