Compare commits

..

2 Commits

Author SHA1 Message Date
gronod 5f3a10ba2e chore: bump version to 0.1.3
Create Release / release (push) Successful in 15s
2026-05-16 00:32:05 +01:00
gronod 063edbf28a Merge develop into main for v0.1.3 2026-05-16 00:31:48 +01:00
162 changed files with 5312 additions and 39982 deletions
+1 -12
View File
@@ -1,4 +1,3 @@
# Docker build context ignores
node_modules/
.env
.env.example
@@ -7,20 +6,10 @@ node_modules/
.gitignore
.DS_Store
*.log
**/*.log
client/node_modules/
client/dist/
client/
dist/
build/
coverage/
tests/
vitest.config.js
.markdownlint.json
README.md
CHANGELOG.md
SECURITY.md
LICENSE
.dockerignore
Dockerfile
.gitea/
docs/
+24
View File
@@ -0,0 +1,24 @@
# Server Configuration
PORT=3001
LOG_LEVEL=info
# Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable and fetch on-demand instead
# POLL_INTERVAL=5000
# 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": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}]
-94
View File
@@ -14,78 +14,6 @@ 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
# =============================================================================
# WEBHOOK SETTINGS
# =============================================================================
# Secret for validating incoming webhooks from Sonarr and Radarr
# Required for webhook endpoints to accept requests
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
# Generate with: openssl rand -hex 32
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Public base URL of Sofarr (for webhook configuration)
# Required for the one-click webhook setup endpoints
# Sonarr/Radarr need this URL to know where to send webhook events
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# --- Webhook Polling Optimization (Phase 5) ---
# Minutes of silence after which the poller falls back to a full poll
# even if webhooks were recently active. Default: 10 minutes.
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
# reduce background polling on very stable setups.
# WEBHOOK_FALLBACK_TIMEOUT=10
# When an instance has received a recent webhook event, the poller skips
# its queue/history fetch entirely (saving API calls). If you still want
# a periodic poll even with webhooks, set this to 1 to disable skipping.
# Default behaviour: skip polling for instances with recent webhook activity.
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
# =============================================================================
# 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.
@@ -124,17 +52,6 @@ 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
@@ -157,12 +74,6 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# RADARR_URL=https://radarr.example.com
# RADARR_API_KEY=your-radarr-api-key
# =============================================================================
# OMBI (Request Management - Optional)
# =============================================================================
OMBI_URL=https://ombi.example.com
OMBI_API_KEY=your-ombi-api-key-here
# =============================================================================
# NOTES
# =============================================================================
@@ -172,9 +83,4 @@ OMBI_API_KEY=your-ombi-api-key-here
# 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
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
# push updates from Sonarr/Radarr and automatically reduce polling load.
# Use the Webhooks Configuration panel in the dashboard UI to enable them
# with one click. The secret must match the header value in each *arr
# notification connection (X-Sofarr-Webhook-Secret).
# =============================================================================
+5 -24
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- 'release/**'
- 'develop*'
- 'develop'
jobs:
build:
@@ -20,37 +20,18 @@ jobs:
BRANCH=${GITHUB_REF#refs/heads/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building develop image tags: ${TAGS}"
if [ "$BRANCH" = "develop" ]; then
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
echo "Building develop image (version ${VERSION})"
else
RELEASE_NAME=${BRANCH#release/}
# Primary registry tags
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
# Gitea package registry tags
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image tags: ${TAGS}"
echo "Building release image ${VERSION} from branch ${BRANCH}"
fi
- name: Log into Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.i3omb.com
username: ${{ github.actor }}
password: ${{ secrets.RELEASE_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
-113
View File
@@ -1,113 +0,0 @@
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
swagger:
name: Swagger Validation & 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: Lint OpenAPI spec with Spectral
run: npx @stoplight/spectral-cli lint server/openapi.yaml --ruleset .spectral.yml || true
- name: Run Swagger coverage tests
run: npm test -- tests/integration/swagger-coverage.test.js
env:
DATA_DIR: /tmp/sofarr-ci-data
SKIP_RATE_LIMIT: "1"
NODE_ENV: test
- name: Generate merged OpenAPI spec
run: npm run generate:openapi
env:
NODE_ENV: test
DATA_DIR: /tmp/sofarr-ci-data
SKIP_RATE_LIMIT: "1"
- name: Convert to RAML
run: npm run generate:raml
continue-on-error: true
- name: Package RAML artifact
run: npm run package:raml
env:
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: Upload RAML package artifact
uses: actions/upload-artifact@v3
if: always()
with:
name: raml-package
path: dist/raml-*.tar.gz
retention-days: 14
-108
View File
@@ -1,108 +0,0 @@
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
-98
View File
@@ -1,98 +0,0 @@
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: |
# First, output all production licenses for visibility
echo "Checking production dependency licenses..."
npx --yes license-checker --production --excludePrivatePackages --json > /tmp/licenses.json
# Check for incompatible licenses
if ! 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;Python-2.0" \
--excludePrivatePackages; then
echo ""
echo "❌ Found incompatible licenses. Full license report:"
cat /tmp/licenses.json
exit 1
fi
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 "./public/*" \
! -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."
echo " Actual first 5 lines:"
head -n 5 "$file" | sed 's/^/ /'
echo ""
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
-8
View File
@@ -1,14 +1,6 @@
node_modules/
coverage/
.env
dist/
build/
.DS_Store
*.log
**/*.log
data/
*.db
*.db-wal
*.db-shm
.agents/
.windsurf/
-18
View File
@@ -1,18 +0,0 @@
{
"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
}
-10
View File
@@ -1,10 +0,0 @@
extends: spectral:oas
rules:
# Ensure all operations have descriptions
operation-description: warn
# Ensure all paths have parameters defined
path-params-defined: error
# Ensure all schemas have examples where appropriate
example-provided: warn
# Disable rules that are too strict for this project
operation-operationId: off
-1266
View File
File diff suppressed because it is too large Load Diff
-476
View File
@@ -1,476 +0,0 @@
# 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.7.10] - 2026-05-24
### Fixed
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
---
## [1.7.9] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.8] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.7] - 2026-05-23
### Fixed
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
---
## [1.7.6] - 2026-05-23
### Fixed
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.5] - 2026-05-23
### Fixed
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
---
## [1.7.4] - 2026-05-23
### Fixed
- **Ombi webhook registration in production** — Fixed a bug where `/api/ombi/webhook/enable` and other `/api/ombi/*` endpoints returned `404 (Not Found)` in production. The core Express app factory (`server/app.js`) registered the router, but the production server entry point (`server/index.js`) duplicated Express setup instead of utilizing the factory, omitting the `ombiRoutes` registration entirely. Re-registered the router in `server/index.js`, resolves Gitea Issue [#40](https://git.i3omb.com/Gandalf/sofarr/issues/40).
---
## [1.7.3] - 2026-05-23
### Fixed
- **Download client dropdown filter icons and type badges** — Refactored the download client selector dropdown in `client/src/ui/filters.js` to render utilizing the design system classes `.download-client-option`, `.download-client-icon`, `.download-client-option-label`, and `.download-client-type`. Options now display the client's brand SVG logo (e.g., `sabnzbd.svg`, `qbittorrent.svg` loaded from `/images/clients/`) next to the configured display name, along with a clean pill badge indicating the client type. This resolves visual ambiguity when multiple download clients share the same name (such as `"i3omb"` for SABnzbd and qBittorrent).
---
## [1.7.2] - 2026-05-22
### Fixed
- **Webhook configuration check** — Ombi webhook status now correctly checks for `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` environment variables. The webhook displays as disabled when either is missing, matching the existing *ARR webhook behavior.
- **Common webhook config endpoint** — Added `GET /api/webhook/config` endpoint that returns whether both `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` are configured, along with a list of missing items. Used by the client to determine webhook enablement status across all webhook types.
- **Client-side webhook status** — Updated `fetchWebhookStatus()` to call `/api/webhook/config` and use the result to determine if Sonarr and Radarr webhooks are enabled. Webhook status now depends on both the service-specific webhook being configured AND the Sofarr webhook config being valid.
- **Webhook config endpoint authentication** — Added `requireAuth` middleware to `GET /api/webhook/config` to enforce authentication, matching the OpenAPI contract and preventing unauthenticated information disclosure.
- **Ombi webhook enable/test config validation** — `POST /api/ombi/webhook/enable` and `POST /api/ombi/webhook/test` now validate `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` before making outbound Ombi API calls, returning `400` with descriptive errors when either is missing. Previously these routes would construct malformed URLs (`undefined/api/webhook/ombi`) if the environment variables were unset.
- **Test environment cleanup** — `tests/integration/webhook.test.js` `afterEach` now cleans up `EMBY_URL`, `SOFARR_BASE_URL`, `SONARR_INSTANCES`, and `RADARR_INSTANCES` to ensure hermetic test isolation.
---
## [1.8.0] - 2026-05-21
### Added
#### Ombi PALDRA Integration
- **OmbiRetriever** — New PALDRA-compliant retriever extending `ArrRetriever`, registered in `arrRetrieverRegistry` alongside Sonarr/Radarr. Manages Ombi request data with 5-minute TTL cache and lookup maps by TMDB/TVDB/IMDB IDs.
- **OmbiClient** — Low-level Ombi API client for HTTP communication (movie/TV requests, search by external ID, connection test).
- **`getOmbiInstances()`** — New config function in `server/utils/config.js` following the existing multi-instance JSON array pattern; supports both `OMBI_INSTANCES` and legacy `OMBI_URL`/`OMBI_API_KEY` formats.
- **PALDRA registry Ombi methods** — `getOmbiRetrievers()`, `getOmbiRequests()`, `getOmbiRequestsByType()`, `findOmbiRequest()` added to `arrRetrieverRegistry`.
- **External ID matching** — Downloads are matched to Ombi requests using TVDB ID → TMDB ID (TV) and TMDB ID → IMDB ID (movies); falls back to an Ombi search link when no request exists.
- **`getOmbiLink()` / `getOmbiSearchLink()`** — New helpers in `DownloadAssembler.js` following the `getSonarrLink`/`getRadarrLink` pattern.
- **Service icon layout** — Downloads and history cards now render inline SVG icons (Ombi for all users; Sonarr/Radarr for admins) instead of linked series/movie names. CSS `.service-icons-container` and `.service-icon` classes added to `public/style.css`.
- **OpenAPI** — `NormalizedDownload` schema extended with `ombiLink`, `ombiRequestId`, `ombiTooltip` nullable string properties; `Ombi` tag added to the spec.
- **`OMBI_INSTANCES` / `OMBI_URL` / `OMBI_API_KEY`** — New environment variables documented in `.env.sample`, `README.md`, `ARCHITECTURE.md`, and `SECURITY.md`.
### Changed
- **`DownloadMatcher.js`** — `matchSabSlots`, `matchSabHistory`, and `matchTorrents` are now `async`; each matched download object is enriched with `ombiLink`, `ombiRequestId`, and `ombiTooltip` via `addOmbiMatching()`.
- **`DownloadBuilder.js`** — `buildUserDownloads` accepts `ombiRetriever` and `ombiBaseUrl` in its options object and passes them through to matching context.
- **Dashboard routes** — Both the REST endpoint and SSE stream now resolve the Ombi retriever from the PALDRA registry and include it in the download-building context.
- **`arrRetrievers.js`** — PALDRA registry now imports `OmbiRetriever`, maps `'ombi'` in `retrieverClasses`, and initialises instances from `getOmbiInstances()`.
- **`ARCHITECTURE.md`** — PALDRA section updated with OmbiRetriever description, registry API additions, and directory-structure entries. Technology stack table updated.
- **`SECURITY.md`** — Threat model extended with Ombi API key exposure and rate-limit exhaustion mitigations.
- **`README.md`** — Prerequisites and new *Ombi Integration (Optional)* configuration section added.
---
## [1.7.1] - 2026-05-21
### Added
#### RAML 1.0 Package Generation
- **Automated RAML generation in CI/CD** — Added RAML 1.0 package generation to the existing `swagger` job in `.gitea/workflows/ci.yml`. The pipeline now generates a downloadable `raml-package` artifact on every push and PR, available from the Actions run page with 14-day retention.
- **RAML generation scripts** — Created three new scripts in `scripts/`:
- `generate-openapi.js` — Bootstraps the Express app in test mode, fetches the merged OpenAPI 3.1 spec from `/api/swagger.json`, and exports it to disk.
- `downgrade-openapi.js` — Downgrades OpenAPI 3.1 to 3.0 for RAML compatibility (existing RAML converters don't support 3.1).
- `simple-raml-converter.js` — Converts OpenAPI 3.0 to RAML 1.0 format using a custom converter (modern RAML converters are unmaintained).
- `package-raml.js` — Creates a versioned tar.gz archive containing the RAML spec, original OpenAPI spec, version metadata, and README.
- **RAML artifact structure** — Each artifact includes:
- `api.raml` — RAML 1.0 specification
- `openapi-merged.json` — Original merged OpenAPI 3.1.0 spec (for reference)
- `version.json` — Metadata (version, commit SHA, timestamp, tool used)
- `README.md` — Origin, conversion details, known limitations, and verification steps
- **npm scripts** — Added three new scripts to `package.json`:
- `generate:openapi` — Generates merged OpenAPI spec
- `generate:raml` — Downgrades and converts to RAML
- `package:raml` — Packages the RAML artifact
- **Spectral ruleset** — Created minimal `.spectral.yml` ruleset to make the existing Spectral lint step functional (previously failing silently due to missing ruleset).
- **OpenAPI JSON endpoint** — Added `/api/swagger.json` endpoint to `server/app.js` to serve the raw merged OpenAPI spec as JSON, enabling programmatic access.
### Changed
- **Dependencies added** — `archiver` (^7.0.1) for creating tar.gz archives.
---
## [1.7.0] - 2026-05-21
### Added
#### Swagger UI & OpenAPI 3.1 Documentation
- **Swagger UI at `/api/swagger`** — Interactive API documentation served via `swagger-ui-express`; publicly accessible with a custom authentication banner (`public/swagger-auth-banner.js`) that explains the cookie-based + CSRF-token authentication flow for testing endpoints directly in the browser.
- **OpenAPI 3.1 specification** — Central `server/openapi.yaml` file containing base metadata, security schemes (`CookieAuth`, `CsrfToken`), and reusable component schemas:
- `NormalizedDownload` — standardised download object returned by all PDCA clients
- `DashboardPayload` — SSE payload shape (`{ user, isAdmin, downloads, downloadClients }`)
- `ErrorResponse` — standard error envelope with redacted details
- `BlocklistSearchRequest` — payload for the admin blocklist-and-search operation
- `WebhookPayload` — Sonarr/Radarr webhook event structure
- `HistoryItem` — deduplicated history record with upgrade-availability flag
- `StatusResponse` — server metrics, polling timings, cache stats, and webhook metrics
- **Hybrid documentation approach** — Per-endpoint details are documented directly in route handler files (`server/routes/*.js`) using JSDoc `@openapi` comments. `swagger-jsdoc` merges these comments with the central YAML at runtime, keeping documentation close to the code while maintaining shared schemas in one place.
- **Comprehensive endpoint coverage** — All implemented endpoints are documented:
- Authentication: `POST /api/auth/login`, `GET /api/auth/me`, `GET /api/auth/csrf`, `POST /api/auth/logout`
- Dashboard: `GET /api/dashboard/stream` (SSE), `GET /api/dashboard/user-downloads` (deprecated), `GET /api/dashboard/cover-art`, `POST /api/dashboard/blocklist-search`
- Status: `GET /api/status`
- History: `GET /api/history/recent`
- Webhooks: `POST /api/webhook/sonarr`, `POST /api/webhook/radarr`
- Proxy routes: Sonarr, Radarr, SABnzbd, and Emby authenticated proxies
- Public health: `GET /health`, `GET /ready`
- **Machine-usable extensions** — Every documented endpoint includes:
- `x-code-samples` with cURL, JavaScript fetch, and TypeScript examples
- `x-integration-notes` section in descriptions for AI agents and automated tooling
- Realistic request/response examples and full JSON Schema definitions
- **Coverage validation test suite** — `tests/integration/swagger-coverage.test.js` (22 tests) validates that:
- The OpenAPI spec loads without YAML parse errors
- Every Express route appears in the merged spec
- All schema and response examples are valid JSON
- Required security schemes (`CookieAuth`, `CsrfToken`) are defined and referenced correctly
- The Swagger UI HTML endpoint (`GET /api/swagger`) returns `200`
- **CI/CD validation job** — Added "Swagger Validation & Coverage" job in `.gitea/workflows/ci.yml` that runs on every push:
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
- Runs `npm test -- tests/integration/swagger-coverage.test.js` to verify coverage
### Changed
- **Dependencies added** — `swagger-ui-express` (^5.0.1), `swagger-jsdoc` (^6.2.8), `yamljs` (^0.3.0), and `@stoplight/spectral-cli` (^6.16.0 dev dependency) for OpenAPI generation, UI serving, and spec linting.
### Security
- **Swagger UI public access** — The Swagger UI endpoint (`/api/swagger`) is publicly accessible by design for convenience. All documented API endpoints still enforce authentication (`emby_user` cookie) and CSRF protection (`X-CSRF-Token` header for mutations) as before. The authentication banner in the UI explicitly instructs users to log in via `POST /api/auth/login` first before testing protected endpoints.
---
## [1.6.0] - 2026-05-21
### Added
- **Staged history loading with SSE push** — history data from Sonarr/Radarr is now fetched in stages; `history-update` SSE events push incremental results to the dashboard so the "Recently Completed" tab populates without waiting for the full fetch.
- **Frontend unit tests** — added Vitest + jsdom test suite covering `client/src/` modules (formatters, storage, state).
- **Comprehensive tests for staged history loading** — backend tests verify the new background-fetch behaviour, cache TTL handling, and SSE emission.
- **Integration test coverage** — new integration and unit tests for `dashboard`, `emby`, `sonarr`, `radarr`, and `sabnzbd` routes.
### Changed
- **Technical-debt remediation — service extraction** — extracted matching and assembly logic from the monolithic `dashboard.js` (~1,360 lines → ~284 lines) into dedicated, testable services:
- `DownloadMatcher.js` — SABnzbd slot / torrent → *arr record matching
- `DownloadAssembler.js` — cover-art, episode, link, blocklist-eligibility helpers
- `DownloadBuilder.js` — orchestrator that reads the cache snapshot and builds the final user-facing download list
- `TagMatcher.js` — tag extraction, sanitisation, and user-ownership matching
- `WebhookStatus.js` — webhook configuration status aggregation
- **Frontend architecture** — migrated from a monolithic `public/app.js` to vanilla ES modules under `client/src/`, bundled by Vite into `public/app.js`.
- **History pagination** — replaced custom date-based cursor pagination with the retriever's built-in pagination (`pageSize=100`, up to 10 pages / 1,000 records total). Eliminates the 40-second response-time regression seen with large histories.
- **Status endpoint path** — admin status route moved from `/api/status/status` to `/api/status` for consistency with the router mount point.
- **Background-fetch safety** — the poller no longer overwrites the cache with empty data when a background history fetch fails or returns no records.
- **SABnzbd progress calculation** — progress is now computed from `slot.mb` and `slot.mbleft` / `slot.mbmissing` because the SABnzbd queue API does not expose a `percentage` field.
- **Speed formatting consistency** — `updateDownloadCard()` now calls `formatSpeed()` so all speed values display with uniform units (B/s, KB/s, MB/s, GB/s).
- **Status-panel error handling** — the panel now surfaces error messages (e.g. `403` for non-admin users) instead of showing a blank box.
### Fixed
- **CSRF token reference errors** — fixed `ReferenceError` bugs in `api.js`, `auth.js`, and the blocklist handler where `csrfToken` and `currentUser` were accessed as bare variables instead of via the shared `state` object.
- **Logout button** — fixed undefined variable references in `handleLogoutClick` that prevented the logout flow from completing.
- **Missing progress bar for SABnzbd** — SABnzbd downloads now render a correct progress bar instead of showing `undefined%`.
- **Status route 404** — corrected the Express router mount so `GET /api/status` responds instead of returning HTML 404.
- **Status button DOM ID** — fixed element-ID mismatch (`status-btn` vs `status-toggle`) that prevented the admin status button from toggling the panel.
- **Tab selection** — fixed tab switching to use `data-tab` attributes after the DOM IDs were removed during the ES-module migration.
- **CSP violations and `ignoreAvailable` reference error** — resolved frontend CSP compliance issues and a missing-variable error in the history tab.
- **Docker client-build stage** — removed `client/` from `.dockerignore` so the multi-stage Dockerfile can run the Vite build during image creation.
- **Unmatched torrent exclusion** — torrents that cannot be matched to a Sonarr/Radarr record are now correctly omitted from the download display.
- **Blocklist button CSRF** — fixed a `ReferenceError` that occurred when a non-admin user clicked the blocklist button.
### Breaking Changes
- **Unmatched torrents are no longer displayed** — torrents that do not match a Sonarr/Radarr queue or history record are excluded from the dashboard. Users who previously saw direct torrent-client downloads will no longer see them unless the *arr services track the item.
- **Frontend build process changed** — the monolithic `public/app.js` source file has been replaced by a Vite build from `client/src/`. Any custom deployment scripts that copy or modify `public/app.js` directly must be updated to run `npm run build` (or use the provided Dockerfile, which already does this).
- **Status API endpoint path changed** — the admin status endpoint moved from `/api/status/status` to `/api/status`. External integrations, monitoring checks, or scripts hitting the old path will receive `404`.
---
## [1.5.5] - 2026-05-20
### Added
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
### Fixed
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
---
## [1.5.4] - 2026-05-19
### Added
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
### Fixed
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
---
## [1.5.3] - 2026-05-19
### Fixed
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
---
## [1.5.2] - 2026-05-19
### Fixed
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
---
## [1.5.1] - 2026-05-19
### Fixed
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
---
## [1.5.0a] - 2026-05-19
### Fixed
- **Status panel close button** — the `×` button now correctly hides the status panel and stops the auto-refresh timer. The button was previously using an inline `onclick` attribute which was silently blocked by the server's CSP nonce policy. Replaced with `addEventListener` wired after `innerHTML` is set, consistent with all other button handlers in the application.
---
## [1.5.0] - 2026-05-19
### Changed
- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline.
- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`.
---
## [1.4.0] - 2026-05-19
### Added
#### Webhook Integration (Phases 15.1)
- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure.
- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle.
- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser.
- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement.
- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event.
- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active.
- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`.
- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events.
#### Smart Polling Optimization (Phase 5)
- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`.
- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead.
- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`.
- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped.
- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`).
- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`).
- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance.
#### Security Hardening (Phase 6)
- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`.
- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message.
- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast.
- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions.
#### Documentation (Phase 6)
- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count.
- **`CHANGELOG.md`** — this entry.
- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints.
- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow.
- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section.
### Changed
- `poller.js``pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data.
- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton.
- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware.
---
## [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.
+8 -53
View File
@@ -1,30 +1,4 @@
# ---------------------------------------------------------------------------
# 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 1.5 — client-build: build frontend with Vite
# ---------------------------------------------------------------------------
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Stage 2 — runtime image (minimal attack surface)
# ---------------------------------------------------------------------------
FROM node:22-alpine AS runtime
FROM node:18-alpine
LABEL org.opencontainers.image.title="sofarr"
LABEL org.opencontainers.image.description="Personal media download dashboard for *arr services"
@@ -35,37 +9,18 @@ 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 production deps from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy application source owned by root (read-only at runtime)
COPY --chown=root:root server/ ./server/
COPY --chown=root:root public/ ./public/
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
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
# Copy application source
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
# 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'
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
-21
View File
@@ -1,21 +0,0 @@
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.
+30 -231
View File
@@ -4,76 +4,39 @@
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
Version 1.7.x adds **interactive Swagger UI and OpenAPI 3.1 documentation** at `/api/swagger` — explore, test, and integrate with the full API using a hybrid YAML + JSDoc documentation system.
## 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, Transmission, rTorrent)
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
- **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
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
## How It Works
### Architecture Overview
```
┌─────────────┐ ┌──────────────────────────────────────────────┐
│ Browser │────▶│ sofarr Server
│ (User) │◀────│ Auth · Dashboard · History · Webhooks
└─────────────┘
SSE push ◀───────│ Poller (smart: skips when webhooks active)
│ Cache · PDCA Download Registry · PALDRA
└───┬─────────────────────────┬────────────────┘
│ polls (background) │ receives webhooks
┌──────────────────────────┐ ┌─────────▼───────────────────┐
│ Download Clients │ │ *arr Services │
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
│ Transmission (Torrent) │ └─────────────────────────────┘
│ rTorrent (Torrent) │
└──────────────────────────┘
Emby / Jellyfin
(User authentication)
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads)
│ (User) │◀────│ Server │ │ qBittorrent (Torrents)
└─────────────┘ └──────────────┘ Sonarr (TV management)
│ │ Radarr (Movie management)
│ │ Emby (User authentication)
▼ └─────────────────────────────┘
┌──────────────┐
│ Dashboard
│ Aggregator │
└──────────────┘
```
**Three pluggable layers power sofarr:**
| Layer | Name | What it does |
|-------|------|--------------|
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
### Webhooks
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
**Quick setup:**
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
2. Open the sofarr dashboard → **Webhooks Configuration** panel
3. Click **Enable** next to each Sonarr/Radarr instance
4. sofarr auto-configures the notification connection inside each *arr service
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
- `POST /api/webhook/sonarr` — receives Sonarr events
- `POST /api/webhook/radarr` — receives Radarr events
### 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, Transmission, or rTorrent) are matched by title to that activity
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity
- Only your downloads appear on your dashboard
### Multi-Instance Support
@@ -88,12 +51,11 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation
- At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
- **Docker** (recommended), or Node.js (v12+) for manual installation
- At least one of: SABnzbd or qBittorrent
- Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking)
- Emby (for user authentication)
- Ombi (optional, for request management integration)
## Docker Deployment (Recommended)
@@ -145,8 +107,6 @@ docker run -d \
-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
@@ -170,8 +130,6 @@ services:
- 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
```
@@ -183,8 +141,8 @@ services:
| Tag | Description |
|-----|-------------|
| `latest` | Latest stable release |
| `1.0` | Latest patch for the 1.0.x release line |
| `1.0.0` | Specific version |
| `0.1` | Latest patch for the 0.1.x release line |
| `0.1.0` | Specific version |
### Updating
@@ -229,30 +187,6 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
# Set to 0 or "off" to disable (on-demand mode)
```
### Webhooks & Smart Polling
```bash
# Required for webhook endpoints to accept events
SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32)
SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup
# Optional tuning
WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10)
WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3)
```
### 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:
@@ -264,21 +198,10 @@ 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)
@@ -292,45 +215,8 @@ 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
```
### Ombi Integration (Optional)
sofarr integrates with Ombi for request management, allowing downloads to be linked to their originating Ombi requests. This provides direct access to request details and enables seamless navigation between downloads and requests.
**Configuration:**
```bash
# JSON array format (recommended for multiple instances)
OMBI_INSTANCES=[{"name":"main","url":"https://ombi.example.com","apiKey":"your-ombi-api-key"}]
# Legacy single-instance format
OMBI_URL=https://ombi.example.com
OMBI_API_KEY=your-ombi-api-key
```
**Features & Architecture:**
- **Request Filtering & Search**: Retrieve, search, and filter requests in real-time by status (pending, approved, available, denied), media type (movies, TV shows), or name.
- **Dual-Layer Filtering & Persistence**: search and core filters are handled server-side for speed and efficiency, while responsive client-side refinements handle on-the-fly rendering. User filter preferences are persisted locally/session-wide to ensure a consistent experience across page refreshes.
- **Real-Time SSE Updates**: Integrates with the Server-Sent Events (SSE) push stream. When requests are created or updated, the dashboard is instantly notified without client-side polling.
- **External ID Matching**: Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB).
- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback)
- **Movies**: TMDB ID (primary) → IMDB ID (fallback)
- Matching is performed automatically using data from Sonarr/Radarr.
- **Interactive UI**: When a matching request is found, an Ombi icon appears in the download card to open the request page. If no request exists, a search link is provided instead.
- **Fully Optional**: Integration is fully optional — sofarr works perfectly without Ombi configured.
## Setting Up User Tags
To see your downloads, you need to tag your media in Sonarr/Radarr:
@@ -359,12 +245,11 @@ sofarr polls all configured services in the background and caches the results. D
| `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.
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
### Real-Time Updates
- **Server-Sent Events (SSE)** — the server pushes updates to the browser immediately after each poll cycle. No client-side timer; no wasted requests.
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering)
- Browser reconnects automatically on network interruption
### Download Information Displayed
- **Progress bar** with visual completion percentage
@@ -377,97 +262,23 @@ sofarr polls all configured services in the background and caches the results. D
### For qBittorrent Downloads
- **Seeds** - Number of seeders
- **Peers** - Number of peers
- **Availability** - Percentage available in swarm (shown in red when below 100%)
## API Documentation (Swagger UI)
sofarr provides interactive API documentation via Swagger UI, available at:
**`http://your-server:3001/api/swagger`**
### Authentication in Swagger UI
sofarr uses cookie-based authentication with Emby/Jellyfin. To test authenticated endpoints in Swagger UI:
1. **Login via Swagger UI:**
- Expand `POST /api/auth/login`
- Click "Try it out"
- Enter your Emby username and password
- Click "Execute"
- The browser will automatically save the session cookies
2. **For state-changing requests (POST/PUT/PATCH/DELETE):**
- Swagger UI automatically includes the `X-CSRF-Token` header from your cookies
- No manual header configuration needed
3. **For GET requests:**
- Cookies are sent automatically
- No additional configuration needed
### Hybrid Documentation Approach
sofarr uses a hybrid documentation model to maintain clean, maintainable API documentation:
- **Central OpenAPI Specification (`server/openapi.yaml`)**: Contains base metadata, security schemes, component schemas (NormalizedDownload, DashboardPayload, ErrorResponse, etc.), and path definitions. This is the single source of truth for shared data structures and global configuration.
- **JSDoc `@openapi` Comments in Route Files**: Per-endpoint details are documented directly in the route handler files (`server/routes/*.js`) using JSDoc `@openapi` comments. swagger-jsdoc merges these comments with the central YAML at runtime, keeping documentation close to the code while maintaining a clean separation of concerns.
This approach provides:
- **Maintainability**: Endpoint details live alongside the code they document
- **Consistency**: Shared schemas are defined once in the central YAML
- **Flexibility**: Easy to update documentation when code changes
- **Machine-Usability**: Full JSON Schema with realistic examples, code samples, and integration notes for AI agents and automated tools
### Proxy Routes
The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/emby/**) are authenticated proxies to upstream services. These endpoints reflect the APIs of Sonarr, Radarr, SABnzbd, and Emby respectively, and are documented to show the specific endpoints implemented in sofarr (not the full upstream API surface).
- **Availability** - Percentage available in swarm
## API Endpoints
### Authentication
- `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
- `POST /api/auth/login` - Login with Emby credentials
- `POST /api/auth/logout` - Logout and clear session
### Dashboard
- `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
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
- `POST /api/webhook/radarr` — receive Radarr webhook events
- `POST /api/webhook/ombi` — receive Ombi webhook events
### Webhook Management (requires auth + CSRF)
- `GET /api/webhook/config` — get webhook configuration status
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection
- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
- `POST /api/radarr/webhook/test` — trigger a Radarr test event
- `GET /api/ombi/webhook/status` — get Ombi webhook status and metrics
- `POST /api/ombi/webhook/enable` — one-click enable Sofarr webhook in Ombi
- `POST /api/ombi/webhook/test` — trigger an Ombi test event
### Ombi (requires auth)
- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` — SABnzbd 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/qbittorrent/*` - qBittorrent API proxy
- `GET /api/sonarr/*` - Sonarr API proxy
- `GET /api/radarr/*` - Radarr API proxy
- `GET /api/emby/*` - Emby API proxy
## Logging Levels
@@ -497,17 +308,6 @@ 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
```
Over 830 tests across 39 files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, webhook metrics integration, polling retrievers, and Ombi filter/search logic. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development
```bash
@@ -525,4 +325,3 @@ MIT
---
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
-178
View File
@@ -1,178 +0,0 @@
# Security Policy & Hardening Guide
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.7.x | ✅ Yes |
| 1.6.x | ✅ Yes |
| 1.5.x | ✅ Yes |
| 1.4.x | ❌ No |
| 1.3.x | ❌ No |
| 1.2.x | ❌ No |
| 1.1.x | ❌ No |
| 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 |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
| API documentation disclosure | Swagger UI at `/api/swagger` publicly exposes endpoint structure; mitigated by endpoint auth requirements and CSRF protection on all mutations |
| Ombi API key exposure | API keys stored in environment variables, never logged; `sanitizeError()` redacts Ombi credentials; Ombi retriever uses 5-minute cache to minimize API calls |
| Ombi rate limit exhaustion | Ombi retriever includes 5-minute TTL cache to reduce API call frequency; graceful degradation if Ombi is unavailable |
---
## 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
### Webhook-Specific (if using webhook integration)
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
### 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 |
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
| `GET /api/swagger` | No rate limit (public documentation) |
---
## 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
-6
View File
@@ -1,6 +0,0 @@
# 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
-22
View File
@@ -1,22 +0,0 @@
-----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-----
-28
View File
@@ -1,28 +0,0 @@
-----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-----
+306
View File
@@ -0,0 +1,306 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.app-header {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.app-header h1 {
color: #333;
font-size: 2rem;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
background: #f0f0f0;
padding: 10px 20px;
border-radius: 20px;
}
.user-label {
color: #666;
font-weight: 500;
}
.user-name {
color: #667eea;
font-weight: bold;
font-size: 1.1rem;
}
.controls {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.controls label {
color: #333;
font-weight: 500;
}
.session-select {
flex: 1;
min-width: 200px;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
}
.session-select:focus {
outline: none;
border-color: #667eea;
}
.refresh-btn {
padding: 10px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background 0.3s;
}
.refresh-btn:hover {
background: #5568d3;
}
.error-message {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
border-left: 4px solid #c33;
}
.loading {
text-align: center;
padding: 40px;
color: white;
font-size: 1.2rem;
}
.downloads-container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.downloads-container h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.no-downloads {
text-align: center;
padding: 40px;
color: #666;
}
.no-downloads p {
margin: 10px 0;
}
.downloads-list {
display: grid;
gap: 20px;
}
.download-card {
border: 2px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
gap: 20px;
align-items: flex-start;
}
.download-cover {
flex-shrink: 0;
width: 80px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.download-cover img {
width: 100%;
height: auto;
display: block;
}
.download-info {
flex: 1;
min-width: 0;
}
.download-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
}
.download-card.series {
border-left: 4px solid #667eea;
}
.download-card.movie {
border-left: 4px solid #f093fb;
}
.download-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.download-type {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.download-type.series {
background: #e8eaf6;
color: #667eea;
}
.download-type.movie {
background: #fce4ec;
color: #f093fb;
}
.download-status {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
text-transform: capitalize;
}
.download-status.downloading {
background: #e8f5e9;
color: #4caf50;
}
.download-status.completed {
background: #e3f2fd;
color: #2196f3;
}
.download-status.failed {
background: #ffebee;
color: #f44336;
}
.download-title {
color: #333;
margin-bottom: 10px;
font-size: 1.2rem;
}
.download-series,
.download-movie {
color: #666;
margin-bottom: 15px;
font-style: italic;
}
.download-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-label {
color: #999;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
color: #333;
font-weight: 500;
}
.app-footer {
margin-top: 20px;
text-align: center;
color: white;
font-size: 0.9rem;
}
.app-footer p {
opacity: 0.9;
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.download-details {
grid-template-columns: 1fr;
}
}
+187
View File
@@ -0,0 +1,187 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
function App() {
const [sessionId, setSessionId] = useState('');
const [currentUser, setCurrentUser] = useState(null);
const [downloads, setDownloads] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]);
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const response = await axios.get('/api/emby/sessions');
setSessions(response.data);
// Auto-select first active session
const activeSession = response.data.find(s => s.NowPlayingItem || s.Active);
if (activeSession) {
setSessionId(activeSession.Id);
fetchUserDownloads(activeSession.Id);
}
} catch (err) {
setError('Failed to fetch Emby sessions. Make sure Emby is running and configured.');
console.error(err);
}
};
const fetchUserDownloads = async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await axios.get(`/api/dashboard/user-downloads/${sessionId}`);
setCurrentUser(response.data.user);
setDownloads(response.data.downloads);
} catch (err) {
setError('Failed to fetch downloads. Make sure all services are configured.');
console.error(err);
} finally {
setLoading(false);
}
};
const handleSessionChange = (e) => {
const newSessionId = e.target.value;
setSessionId(newSessionId);
if (newSessionId) {
fetchUserDownloads(newSessionId);
}
};
const formatSize = (bytes) => {
if (!bytes) return 'N/A';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
};
return (
<div className="app">
<header className="app-header">
<h1>Media Download Dashboard</h1>
{currentUser && (
<div className="user-info">
<span className="user-label">Current User:</span>
<span className="user-name">{currentUser}</span>
</div>
)}
</header>
<div className="controls">
<label htmlFor="session-select">Select Emby Session:</label>
<select
id="session-select"
value={sessionId}
onChange={handleSessionChange}
className="session-select"
>
<option value="">-- Select Session --</option>
{sessions.map(session => (
<option key={session.Id} value={session.Id}>
{session.UserName} - {session.Client} {session.NowPlayingItem ? `(Playing: ${session.NowPlayingItem.Name})` : '(Idle)'}
</option>
))}
</select>
<button onClick={fetchSessions} className="refresh-btn">Refresh Sessions</button>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{loading && (
<div className="loading">Loading downloads...</div>
)}
{!loading && !error && (
<div className="downloads-container">
<h2>Your Downloads</h2>
{downloads.length === 0 ? (
<div className="no-downloads">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with "user:yourusername" in Sonarr/Radarr.</p>
</div>
) : (
<div className="downloads-list">
{downloads.map((download, index) => (
<div key={index} className={`download-card ${download.type}`}>
{download.coverArt && (
<div className="download-cover">
<img src={download.coverArt} alt={download.movieName || download.seriesName || download.title} />
</div>
)}
<div className="download-info">
<div className="download-header">
<span className={`download-type ${download.type}`}>
{download.type === 'series' ? '📺 Series' : '🎬 Movie'}
</span>
<span className={`download-status ${download.status}`}>
{download.status}
</span>
</div>
<h3 className="download-title">{download.title}</h3>
{download.seriesName && (
<p className="download-series">Series: {download.seriesName}</p>
)}
{download.movieName && (
<p className="download-movie">Movie: {download.movieName}</p>
)}
<div className="download-details">
<div className="detail-item">
<span className="detail-label">Size:</span>
<span className="detail-value">{formatSize(download.size)}</span>
</div>
{download.progress && (
<div className="detail-item">
<span className="detail-label">Progress:</span>
<span className="detail-value">{download.progress}%</span>
</div>
)}
{download.speed && (
<div className="detail-item">
<span className="detail-label">Speed:</span>
<span className="detail-value">{download.speed}</span>
</div>
)}
{download.eta && (
<div className="detail-item">
<span className="detail-label">ETA:</span>
<span className="detail-value">{download.eta}</span>
</div>
)}
{download.completedAt && (
<div className="detail-item">
<span className="detail-label">Completed:</span>
<span className="detail-value">{formatDate(download.completedAt)}</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
<footer className="app-footer">
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
</footer>
</div>
);
}
export default App;
-351
View File
@@ -1,351 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from './state.js';
export async function checkAuthentication() {
try {
// 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) state.csrfToken = csrfData.csrfToken;
if (data.authenticated) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
return { authenticated: true, user: data.user };
} else {
return { authenticated: false };
}
} catch (err) {
console.error('Authentication check failed:', err);
return { authenticated: false };
}
}
export async function handleLogin(username, password, rememberMe) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
// Store CSRF token returned by login for use in subsequent requests
if (data.csrfToken) state.csrfToken = data.csrfToken;
return { success: true, user: data.user };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (err) {
console.error(err);
return { success: false, error: 'Login failed. Please try again.' };
}
}
export async function handleLogout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: state.csrfToken ? { 'X-CSRF-Token': state.csrfToken } : {}
});
state.currentUser = null;
state.csrfToken = null;
return { success: true };
} catch (err) {
console.error('Logout failed:', err);
return { success: false };
}
}
export async function loadHistory(forceRefresh = false) {
try {
const params = new URLSearchParams({ days: state.historyDays });
if (state.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();
return { success: true, history: data.history || [] };
} catch (err) {
console.error('[History] Load error:', err);
return { success: false, error: 'Failed to load history.' };
}
}
export async function handleBlocklistSearch(download) {
try {
const res = await fetch('/api/dashboard/blocklist-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.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}`);
}
return { success: true };
} catch (err) {
console.error('[Blocklist] Error:', err);
throw err;
}
}
export async function loadAppVersion() {
try {
const res = await fetch('/health');
const data = await res.json();
return data.version || null;
} catch (err) {
return null;
}
}
export async function fetchWebhookMetrics() {
try {
const res = await fetch('/api/dashboard/webhook-metrics');
if (!res.ok) return null;
return await res.json();
} catch (err) {
return null;
}
}
export async function fetchWebhookStatus() {
try {
// Fetch metrics in parallel
const metricsPromise = fetchWebhookMetrics();
// Fetch webhook configuration status (checks SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
let webhookConfigValid = false;
try {
const configRes = await fetch('/api/webhook/config');
if (configRes.ok) {
const configData = await configRes.json();
webhookConfigValid = configData.valid || false;
}
} catch (err) {
// Config endpoint not available, assume invalid
}
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (sonarrRes.ok) {
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
sonarrEnabled = webhookConfigValid && !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Sonarr not configured
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (radarrRes.ok) {
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
radarrEnabled = webhookConfigValid && !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Radarr not configured
}
// Fetch Ombi webhook status
let ombiEnabled = false;
let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
let ombiStats = null;
try {
const ombiRes = await fetch('/api/ombi/webhook/status');
if (ombiRes.ok) {
const ombiData = await ombiRes.json();
ombiEnabled = ombiData.enabled || false;
ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
ombiStats = ombiData.stats || null;
}
} catch (err) {
// Ombi not configured
}
state.webhookMetrics = await metricsPromise;
// Find instance stats
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats };
return { success: true };
} catch (err) {
console.error('Failed to fetch webhook status:', err);
return { success: false };
}
}
export async function enableSonarrWebhook() {
try {
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function enableRadarrWebhook() {
try {
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testSonarrWebhook() {
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/sonarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(sonarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testRadarrWebhook() {
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/radarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(radarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function enableOmbiWebhook() {
try {
const res = await fetch('/api/ombi/webhook/enable', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Ombi webhook:', err);
return { success: false, error: err.message };
}
}
export async function testOmbiWebhook() {
try {
const res = await fetch('/api/ombi/webhook/test', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Ombi webhook:', err);
return { success: false, error: err.message };
}
}
export async function refreshStatusPanel() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
const data = await res.json();
return { success: true, data };
} catch (err) {
console.error('[Status] Error fetching status:', err);
return { success: false, error: err.message };
}
}
-64
View File
@@ -1,64 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Bootstrap - wire all event handlers on DOMContentLoaded
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
import { initDownloadClientFilter } from './ui/filters.js';
import { initRequestFilters } from './ui/requestFilters.js';
import { initHistoryControls } from './ui/history.js';
import { toggleStatusPanel } from './ui/statusPanel.js';
import { initWebhooks } from './ui/webhooks.js';
import { initThemeSwitcher } from './ui/theme.js';
import { initTabs, goHome } from './ui/tabs.js';
import { handleShowAllToggle } from './sse.js';
import { loadAppVersion } from './api.js';
document.addEventListener('DOMContentLoaded', () => {
// Login form
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', handleLogin);
}
// Logout button
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogoutClick);
}
// Show all toggle
const showAllToggle = document.getElementById('show-all-toggle');
if (showAllToggle) {
showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked));
}
// Status panel toggle
const statusToggle = document.getElementById('status-btn');
if (statusToggle) {
statusToggle.addEventListener('click', toggleStatusPanel);
}
// Home button
const homeBtn = document.getElementById('home-btn');
if (homeBtn) {
homeBtn.addEventListener('click', goHome);
}
// Initialize UI components
initThemeSwitcher();
initTabs();
initDownloadClientFilter();
initRequestFilters();
initHistoryControls();
initWebhooks();
// Load app version
loadAppVersion().then(version => {
const versionEl = document.getElementById('app-version');
if (versionEl && version) {
versionEl.textContent = 'v' + version;
}
});
// Check authentication and initialize
checkAuthenticationAndInit();
});
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
-84
View File
@@ -1,84 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, SSE_RECONNECT_MS } from './state.js';
import { renderDownloads } from './ui/downloads.js';
import { hideError, hideLoading } from './ui/auth.js';
import { loadHistory } from './ui/history.js';
export function startSSE() {
stopSSE();
const params = state.showAll ? '?showAll=true' : '';
const source = new EventSource('/api/dashboard/stream' + params);
state.sseSource = source;
let firstMessage = true;
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
state.currentUser = data.user;
state.isAdmin = !!data.isAdmin;
state.downloads = data.downloads;
// Store download clients and update filter dropdown
if (data.downloadClients) {
state.downloadClients = data.downloadClients;
// Trigger filter update
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
document.dispatchEvent(filterUpdateEvent);
}
// Store Ombi requests and base URL
if (data.ombiRequests) {
state.ombiRequests = data.ombiRequests;
// Trigger requests update event
const requestsUpdateEvent = new CustomEvent('ombiRequestsUpdated');
document.dispatchEvent(requestsUpdateEvent);
}
if (data.ombiBaseUrl) {
state.ombiBaseUrl = data.ombiBaseUrl;
}
document.getElementById('currentUser').textContent = state.currentUser || '-';
renderDownloads();
hideError();
if (firstMessage) { firstMessage = false; hideLoading(); }
} catch (err) {
console.error('[SSE] Failed to parse message:', err);
}
};
// Listen for history-update events from server
source.addEventListener('history-update', (event) => {
try {
const data = JSON.parse(event.data);
console.log('[SSE] History update received:', data.type);
// Trigger history reload
const historyReloadEvent = new CustomEvent('historyReload');
document.dispatchEvent(historyReloadEvent);
} catch (err) {
console.error('[SSE] Failed to parse history-update 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');
}
export function stopSSE() {
if (state.sseReconnectTimer) { clearTimeout(state.sseReconnectTimer); state.sseReconnectTimer = null; }
if (state.sseSource) {
state.sseSource.close();
state.sseSource = null;
console.log('[SSE] Stream closed');
}
}
export function handleShowAllToggle(checked) {
state.showAll = checked;
// Re-open stream with updated showAll param
startSSE();
// Trigger history reload with updated showAll param
const historyReloadEvent = new CustomEvent('historyReload');
document.dispatchEvent(historyReloadEvent);
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Global state (using objects for mutability across modules)
export const state = {
currentUser: null,
downloads: [],
downloadClients: [], // List of download clients from server (for ordering/filtering)
selectedDownloadClients: [], // Array of selected client IDs for multi-select filter
isAdmin: false,
showAll: false,
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
ombiBaseUrl: null, // Ombi base URL for generating links
ombiRequests: null, // Ombi requests data
// History section state
historyDays: 7, // Default value, will be loaded from localStorage
historyRefreshHandle: null,
ignoreAvailable: false, // Default value, will be loaded from localStorage
lastHistoryItems: [], // raw items from last fetch, for re-filtering without a network round-trip
// SSE stream state
sseSource: null,
sseReconnectTimer: null,
// Status panel state
statusRefreshHandle: null,
// Webhooks state
webhookSectionExpanded: false,
webhookLoading: false,
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
webhookMetrics: null,
// Request filter state
selectedRequestTypes: ['movie', 'tv'],
selectedRequestStatuses: [],
requestSortMode: 'requestedDate_desc',
requestSearchQuery: ''
};
// Constants
export const SPLASH_MIN_MS = 1200; // minimum splash display time
export const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
export const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
export const STATUS_REFRESH_MS = 5000;
-176
View File
@@ -1,176 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, SPLASH_MIN_MS } from '../state.js';
import { checkAuthentication, handleLogin as apiHandleLogin, handleLogout as apiHandleLogout } from '../api.js';
import { startSSE, stopSSE } from '../sse.js';
import { stopHistoryRefresh, clearHistory, startHistoryRefresh } from './history.js';
import { closeStatusPanel } from './statusPanel.js';
export function fadeOutLogin() {
return new Promise(resolve => {
const login = document.getElementById('login-container');
login.classList.add('fade-out');
login.addEventListener('transitionend', () => {
login.classList.add('hidden');
login.classList.remove('fade-out');
resolve();
}, { once: true });
});
}
export function showSplash() {
const splash = document.getElementById('splash-screen');
splash.classList.remove('hidden');
splash.style.opacity = '1';
splash.classList.remove('fade-out');
}
export 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.classList.add('hidden');
resolve();
}, TRANSITION_MS + 100);
splash.addEventListener('transitionend', () => {
clearTimeout(fallback);
splash.classList.add('hidden');
resolve();
}, { once: true });
}, remaining);
});
}
export async function checkAuthenticationAndInit() {
const splashStart = Date.now();
try {
const result = await checkAuthentication();
if (result.authenticated) {
showDashboard();
showLoading();
startSSE();
await dismissSplash(splashStart);
} else {
await dismissSplash(splashStart);
showLogin();
}
} catch (err) {
console.error('Authentication check failed:', err);
await dismissSplash(splashStart);
showLogin();
}
}
export async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try {
const result = await apiHandleLogin(username, password, rememberMe);
if (result.success) {
// 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();
showLoading();
const splashStart = Date.now();
startSSE();
await dismissSplash(splashStart);
} else {
showLoginError(result.error || 'Login failed');
}
} catch (err) {
showLoginError('Login failed. Please try again.');
console.error(err);
}
}
export async function handleLogoutClick() {
try {
stopSSE();
stopHistoryRefresh();
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
await apiHandleLogout();
state.currentUser = null;
clearHistory();
showLogin();
} catch (err) {
console.error('Logout failed:', err);
}
}
export function showLogin() {
document.getElementById('login-container').classList.remove('hidden');
document.getElementById('dashboard-container').classList.add('hidden');
hideLoginError();
}
export function showDashboard() {
document.getElementById('login-container').classList.add('hidden');
document.getElementById('dashboard-container').classList.remove('hidden');
document.getElementById('currentUser').textContent = state.currentUser.name || '-';
// Always start with status panel hidden (guards against stale display value on re-login)
const sp = document.getElementById('status-panel');
sp.classList.add('hidden');
// Also hide webhooks-section to keep them in sync (both show/hide together)
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.classList.add('hidden');
const adminControls = document.getElementById('admin-controls');
if (state.isAdmin) {
adminControls.classList.remove('hidden');
} else {
adminControls.classList.add('hidden');
}
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
// Initialise days input from saved value
const daysInput = document.getElementById('history-days');
if (daysInput) daysInput.value = state.historyDays;
startHistoryRefresh();
}
export function showLoginError(message) {
const errorDiv = document.getElementById('login-error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
export function hideLoginError() {
const errorDiv = document.getElementById('login-error');
errorDiv.classList.add('hidden');
}
export function showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
export function hideError() {
const errorDiv = document.getElementById('error-message');
errorDiv.classList.add('hidden');
}
export function showLoading() {
const loading = document.getElementById('loading');
loading.classList.remove('hidden');
}
export function hideLoading() {
const loading = document.getElementById('loading');
loading.classList.add('hidden');
}
-565
View File
@@ -1,565 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
import { handleBlocklistSearch } from '../api.js';
export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
const fragment = document.createDocumentFragment();
if (showAll && tagBadges && tagBadges.length > 0) {
const unmatched = tagBadges.filter(b => !b.matchedUser);
const matched = 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;
fragment.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
fragment.appendChild(badge);
}
} else if (matchedUserTag) {
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = matchedUserTag;
fragment.appendChild(matchedBadge);
}
return fragment;
}
function createClientLogo(download) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
const clientLogo = document.createElement('img');
clientLogo.className = 'download-client-logo';
clientLogo.src = `/images/clients/${download.client}.svg`;
clientLogo.alt = `${download.instanceName || download.client} icon`;
clientLogo.title = download.instanceName || download.client;
clientLogo.onerror = () => {
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
};
clientLogoWrapper.appendChild(clientLogo);
return clientLogoWrapper;
}
function createServiceIcons(download) {
const container = document.createElement('span');
container.className = 'service-icons-container';
// Add Ombi icon for all users if ombiLink exists
if (download.ombiLink) {
const ombiIcon = document.createElement('img');
ombiIcon.className = 'service-icon ombi';
ombiIcon.src = '/images/ombi.svg';
ombiIcon.alt = 'Ombi';
ombiIcon.title = download.ombiTooltip || 'Ombi';
ombiIcon.href = download.ombiLink;
const ombiLink = document.createElement('a');
ombiLink.href = download.ombiLink;
ombiLink.target = '_blank';
ombiLink.appendChild(ombiIcon);
container.appendChild(ombiLink);
}
// Add Sonarr/Radarr icon for admin users if arrLink exists
if (state.isAdmin && download.arrLink) {
const arrIcon = document.createElement('img');
if (download.arrType === 'sonarr') {
arrIcon.className = 'service-icon sonarr';
arrIcon.src = '/images/sonarr.svg';
arrIcon.alt = 'Sonarr';
} else if (download.arrType === 'radarr') {
arrIcon.className = 'service-icon radarr';
arrIcon.src = '/images/radarr.svg';
arrIcon.alt = 'Radarr';
}
arrIcon.title = download.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
const arrLink = document.createElement('a');
arrLink.href = download.arrLink;
arrLink.target = '_blank';
arrLink.appendChild(arrIcon);
container.appendChild(arrLink);
}
return container;
}
export function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
// Filter downloads by selected clients
let filteredDownloads = state.downloads;
if (state.selectedDownloadClients.length > 0) {
// Map indices to client objects, then filter by both client type and instanceId
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
filteredDownloads = state.downloads.filter(d =>
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
);
}
// Sort downloads by client order (matching the order in downloadClients)
if (state.downloadClients.length > 0) {
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
filteredDownloads = [...filteredDownloads].sort((a, b) => {
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
return orderA - orderB;
});
}
if (filteredDownloads.length === 0) {
noDownloads.classList.remove('hidden');
downloadsList.innerHTML = '';
return;
}
noDownloads.classList.add('hidden');
// Get existing cards
const existingCards = new Map();
downloadsList.querySelectorAll('.download-card').forEach(card => {
existingCards.set(card.dataset.id, card);
});
// Track which downloads we've processed
const processedIds = new Set();
filteredDownloads.forEach(download => {
const id = download.title;
processedIds.add(id);
const existingCard = existingCards.get(id);
if (existingCard) {
// Update existing card
updateDownloadCard(existingCard, download);
} else {
// Create new card
const card = createDownloadCard(download);
downloadsList.appendChild(card);
}
});
// Remove cards for downloads that no longer exist
existingCards.forEach((card, id) => {
if (!processedIds.has(id)) {
card.remove();
}
});
}
export function updateDownloadCard(card, download) {
// Remove old header-right container if it exists
const oldRightSide = card.querySelector('.download-header-right');
if (oldRightSide) {
oldRightSide.remove();
}
// Remove old user badges directly in header
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
oldBadges.forEach(badge => badge.remove());
// Remove old client logo from header (old structure)
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
if (oldLogoInHeader) {
oldLogoInHeader.remove();
}
// Remove old client logo from card (new structure) if it exists
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
if (oldLogoInCard) {
oldLogoInCard.remove();
}
// Add new right-side container with user badge only
const header = card.querySelector('.download-header');
if (header && !header.querySelector('.download-header-right')) {
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
rightSide.appendChild(badges);
header.appendChild(rightSide);
}
// Add client logo to card (positioned at bottom right via CSS)
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
card.appendChild(createClientLogo(download));
}
// Update status
const statusEl = card.querySelector('.download-status');
if (statusEl && statusEl.textContent !== download.status) {
statusEl.textContent = download.status;
statusEl.className = `download-status ${download.status}`;
}
// Update progress bar and missing pieces
const progressContainer = card.querySelector('.progress-container');
if (progressContainer && download.progress !== undefined) {
const progressBar = progressContainer.querySelector('.progress-bar');
const progressText = progressContainer.querySelector('.progress-text');
const missingText = progressContainer.querySelector('.missing-text');
if (progressBar) {
const downloaded = progressBar.querySelector('.downloaded');
if (downloaded) {
downloaded.style.width = download.progress + '%';
}
}
if (progressText) {
progressText.textContent = download.progress + '%';
}
if (missingText) {
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
if (missingMb > 0 && totalMb > 0) {
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
} else {
missingText.textContent = '';
}
}
}
// Update speed
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
if (speedEl && download.speed !== undefined) {
speedEl.textContent = formatSpeed(download.speed);
}
// Update ETA
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
if (etaEl && download.eta !== undefined) {
etaEl.textContent = download.eta;
}
// Update qBittorrent-specific fields
if (download.qbittorrent) {
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
if (seedsEl && download.seeds !== undefined) {
seedsEl.textContent = download.seeds;
}
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
if (peersEl && download.peers !== undefined) {
peersEl.textContent = download.peers;
}
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);
}
}
}
export async function handleBlocklistSearchClick(btn, download) {
console.log('[Blocklist] Clicked, download:', download);
console.log('[Blocklist] Required fields:', {
arrQueueId: download.arrQueueId,
arrType: download.arrType,
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentType: download.arrContentType,
isAdmin: state.isAdmin,
canBlocklist: download.canBlocklist
});
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 {
await handleBlocklistSearch(download);
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 an automatic search';
}, 4000);
}
}
export function createDownloadCard(download) {
const card = document.createElement('div');
card.className = `download-card ${download.type}`;
card.dataset.id = download.title;
// Cover art
if (download.coverArt) {
const coverDiv = document.createElement('div');
coverDiv.className = 'download-cover';
const coverImg = document.createElement('img');
// 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);
card.appendChild(coverDiv);
}
// Info wrapper
const infoDiv = document.createElement('div');
infoDiv.className = 'download-info';
const header = document.createElement('div');
header.className = 'download-header';
const type = document.createElement('span');
type.className = `download-type ${download.type}`;
if (download.type === 'series') {
type.textContent = '📺 Series';
} else if (download.type === 'movie') {
type.textContent = '🎬 Movie';
} else if (download.type === 'torrent') {
const instName = download.instanceName ? ` (${download.instanceName})` : '';
type.textContent = `📥 Torrent${instName}`;
} else {
type.textContent = download.type;
}
const status = document.createElement('span');
status.className = `download-status ${download.status}`;
status.textContent = download.status;
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 ((state.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', () => handleBlocklistSearchClick(blBtn, download));
header.appendChild(blBtn);
}
// Right side container for user badge only
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
rightSide.appendChild(badges);
header.appendChild(rightSide);
// Add client logo to card (positioned at bottom right via CSS)
if (download.client) {
card.appendChild(createClientLogo(download));
}
const title = document.createElement('h3');
title.className = 'download-title';
title.textContent = download.title;
infoDiv.appendChild(header);
infoDiv.appendChild(title);
if (download.seriesName) {
const series = document.createElement('p');
series.className = 'download-series';
// Add service icons
const serviceIcons = createServiceIcons(download);
if (serviceIcons.hasChildNodes()) {
series.appendChild(serviceIcons);
series.appendChild(document.createTextNode(' '));
}
// Series name is now plain text for all users (no link)
const seriesText = document.createElement('span');
seriesText.textContent = `Series: ${download.seriesName}`;
series.appendChild(seriesText);
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';
// Add service icons
const serviceIcons = createServiceIcons(download);
if (serviceIcons.hasChildNodes()) {
movie.appendChild(serviceIcons);
movie.appendChild(document.createTextNode(' '));
}
// Movie name is now plain text for all users (no link)
const movieText = document.createElement('span');
movieText.textContent = `Movie: ${download.movieName}`;
movie.appendChild(movieText);
infoDiv.appendChild(movie);
}
const details = document.createElement('div');
details.className = 'download-details';
const size = createDetailItem('Size', formatSize(download.size));
details.appendChild(size);
if (download.progress !== undefined) {
const progressItem = document.createElement('div');
progressItem.className = 'detail-item progress-item';
progressItem.dataset.label = 'Progress';
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = 'Progress';
const valueDiv = document.createElement('div');
valueDiv.className = 'progress-container';
// Progress bar with segments
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
const downloadedMb = totalMb - missingMb;
const progressPercent = parseFloat(download.progress) || 0;
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
// Downloaded portion (green)
if (progressPercent > 0) {
const downloaded = document.createElement('div');
downloaded.className = 'progress-segment downloaded';
downloaded.style.width = progressPercent + '%';
progressBar.appendChild(downloaded);
}
valueDiv.appendChild(progressBar);
// Text showing percentage
const progressText = document.createElement('span');
progressText.className = 'progress-text';
progressText.textContent = download.progress + '%';
valueDiv.appendChild(progressText);
// Missing pieces text (only for torrent clients like qBittorrent)
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
const missingText = document.createElement('span');
missingText.className = 'missing-text';
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
valueDiv.appendChild(missingText);
}
progressItem.appendChild(labelSpan);
progressItem.appendChild(valueDiv);
details.appendChild(progressItem);
}
if (download.speed && download.speed > 0) {
const speed = createDetailItem('Speed', formatSpeed(download.speed));
details.appendChild(speed);
}
if (download.eta) {
const eta = createDetailItem('ETA', download.eta);
details.appendChild(eta);
}
// qBittorrent-specific fields
if (download.qbittorrent) {
if (download.seeds !== undefined) {
const seeds = createDetailItem('Seeds', download.seeds);
details.appendChild(seeds);
}
if (download.peers !== undefined) {
const peers = createDetailItem('Peers', download.peers);
details.appendChild(peers);
}
if (download.availability !== undefined) {
const availability = createDetailItem('Availability', `${download.availability}%`);
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
details.appendChild(availability);
}
}
if (download.completedAt) {
const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed);
}
if (state.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);
return card;
}
export function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.dataset.label = label;
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'detail-value';
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
return item;
}
function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
-130
View File
@@ -1,130 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { saveDownloadClients } from '../utils/storage.js';
import { renderDownloads } from './downloads.js';
export function initDownloadClientFilter() {
const filterBtn = document.getElementById('download-client-dropdown-btn');
const filterDropdown = document.getElementById('download-client-dropdown');
const selectAllBtn = document.getElementById('download-client-select-all');
const deselectAllBtn = document.getElementById('download-client-deselect-all');
if (!filterBtn || !filterDropdown) return;
filterBtn.addEventListener('click', (e) => {
e.stopPropagation();
filterDropdown.classList.toggle('open');
});
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => toggleAllClients(true));
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', () => toggleAllClients(false));
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!filterDropdown.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) {
filterDropdown.classList.remove('open');
}
});
// Listen for download clients updates from SSE
document.addEventListener('downloadClientsUpdated', updateDownloadClientFilter);
// Initial filter update
updateDownloadClientFilter();
}
export function updateDownloadClientFilter() {
const filterList = document.getElementById('download-client-options');
if (!filterList) return;
filterList.innerHTML = '';
state.downloadClients.forEach((client, index) => {
const item = document.createElement('div');
item.className = 'download-client-option';
item.dataset.index = index;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'download-client-checkbox';
checkbox.id = `client-${index}`;
checkbox.checked = state.selectedDownloadClients.includes(index);
checkbox.addEventListener('change', () => toggleClientSelection(index));
const iconWrapper = document.createElement('span');
iconWrapper.className = 'download-client-icon';
const iconImg = document.createElement('img');
iconImg.src = `/images/clients/${client.type}.svg`;
iconImg.alt = `${client.name || client.type} icon`;
iconImg.onerror = () => {
iconWrapper.textContent = client.type.charAt(0).toUpperCase();
iconWrapper.classList.add('fallback');
};
iconWrapper.appendChild(iconImg);
const label = document.createElement('label');
label.className = 'download-client-option-label';
label.htmlFor = `client-${index}`;
label.textContent = client.name || `${client.type} (${client.id})`;
const typeBadge = document.createElement('span');
typeBadge.className = 'download-client-type';
typeBadge.textContent = client.type;
item.appendChild(checkbox);
item.appendChild(iconWrapper);
item.appendChild(label);
item.appendChild(typeBadge);
filterList.appendChild(item);
});
updateSelectedCountDisplay();
}
export function toggleClientSelection(index) {
const idx = state.selectedDownloadClients.indexOf(index);
if (idx > -1) {
state.selectedDownloadClients.splice(idx, 1);
} else {
state.selectedDownloadClients.push(index);
}
saveDownloadClients(state.selectedDownloadClients);
updateSelectedCountDisplay();
renderDownloads();
}
export function toggleAllClients(select) {
if (select) {
state.selectedDownloadClients = state.downloadClients.map((_, index) => index);
} else {
state.selectedDownloadClients = [];
}
saveDownloadClients(state.selectedDownloadClients);
updateDownloadClientFilter();
renderDownloads();
}
export function updateSelectedCountDisplay() {
const countDisplay = document.getElementById('download-client-selected-text');
if (!countDisplay) return;
if (state.selectedDownloadClients.length === 0) {
countDisplay.textContent = 'All clients';
} else if (state.selectedDownloadClients.length === state.downloadClients.length) {
countDisplay.textContent = 'All clients';
} else {
const names = state.selectedDownloadClients
.map(idx => state.downloadClients[idx]?.name || state.downloadClients[idx]?.type || '')
.filter(Boolean);
if (names.length === 1) {
countDisplay.textContent = names[0];
} else {
countDisplay.textContent = `${state.selectedDownloadClients.length} clients`;
}
}
}
-283
View File
@@ -1,283 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, HISTORY_REFRESH_MS } from '../state.js';
import { loadHistory as apiLoadHistory } from '../api.js';
import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
import { renderTagBadges } from './downloads.js';
function createServiceIcons(item) {
const container = document.createElement('span');
container.className = 'service-icons-container';
// Add Ombi icon for all users if ombiLink exists
if (item.ombiLink) {
const ombiIcon = document.createElement('img');
ombiIcon.className = 'service-icon ombi';
ombiIcon.src = '/images/ombi.svg';
ombiIcon.alt = 'Ombi';
ombiIcon.title = item.ombiTooltip || 'Ombi';
const ombiLink = document.createElement('a');
ombiLink.href = item.ombiLink;
ombiLink.target = '_blank';
ombiLink.appendChild(ombiIcon);
container.appendChild(ombiLink);
}
// Add Sonarr/Radarr icon for admin users if arrLink exists
if (state.isAdmin && item.arrLink) {
const arrIcon = document.createElement('img');
if (item.arrType === 'sonarr') {
arrIcon.className = 'service-icon sonarr';
arrIcon.src = '/images/sonarr.svg';
arrIcon.alt = 'Sonarr';
} else if (item.arrType === 'radarr') {
arrIcon.className = 'service-icon radarr';
arrIcon.src = '/images/radarr.svg';
arrIcon.alt = 'Radarr';
}
arrIcon.title = item.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
const arrLink = document.createElement('a');
arrLink.href = item.arrLink;
arrLink.target = '_blank';
arrLink.appendChild(arrIcon);
container.appendChild(arrLink);
}
return container;
}
export 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;
saveHistoryDays(v);
loadHistory(true);
}
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadHistory(true));
}
if (ignoreToggle) {
ignoreToggle.checked = state.ignoreAvailable;
ignoreToggle.addEventListener('change', () => {
state.ignoreAvailable = ignoreToggle.checked;
saveIgnoreAvailable(state.ignoreAvailable);
renderHistory(state.lastHistoryItems);
});
}
// Listen for history reload events from other modules
document.addEventListener('historyReload', () => {
loadHistory(true);
});
}
export function startHistoryRefresh() {
stopHistoryRefresh();
state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
}
export function stopHistoryRefresh() {
if (state.historyRefreshHandle) {
clearInterval(state.historyRefreshHandle);
state.historyRefreshHandle = null;
}
}
export function clearHistory() {
state.lastHistoryItems = [];
document.getElementById('history-list').innerHTML = '';
document.getElementById('no-history').classList.add('hidden');
document.getElementById('history-error').classList.add('hidden');
}
export 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.classList.remove('hidden');
errorEl.classList.add('hidden');
noHistoryEl.classList.add('hidden');
try {
const result = await apiLoadHistory(forceRefresh);
loadingEl.classList.add('hidden');
if (result.success) {
state.lastHistoryItems = result.history;
renderHistory(state.lastHistoryItems);
} else {
errorEl.textContent = result.error || 'Failed to load history.';
errorEl.classList.remove('hidden');
}
} catch (err) {
loadingEl.classList.add('hidden');
errorEl.textContent = 'Failed to load history.';
errorEl.classList.remove('hidden');
console.error('[History] Load error:', err);
}
}
export function renderHistory(items) {
const listEl = document.getElementById('history-list');
const noHistoryEl = document.getElementById('no-history');
listEl.innerHTML = '';
const visible = state.ignoreAvailable
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
: items;
if (!visible.length) {
noHistoryEl.classList.remove('hidden');
return;
}
noHistoryEl.classList.add('hidden');
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
}
export 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);
}
const badges = renderTagBadges(item.tagBadges, state.showAll, item.matchedUserTag);
header.appendChild(badges);
info.appendChild(header);
// Title
const title = document.createElement('h3');
title.className = 'history-title';
title.textContent = item.title;
info.appendChild(title);
// Series/movie name with service icons
if (item.seriesName) {
const p = document.createElement('p');
p.className = 'history-media-name';
// Add service icons
const serviceIcons = createServiceIcons(item);
if (serviceIcons.hasChildNodes()) {
p.appendChild(serviceIcons);
p.appendChild(document.createTextNode(' '));
}
// Series name is now plain text for all users (no link)
const seriesText = document.createElement('span');
seriesText.textContent = 'Series: ' + item.seriesName;
p.appendChild(seriesText);
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';
// Add service icons
const serviceIcons = createServiceIcons(item);
if (serviceIcons.hasChildNodes()) {
p.appendChild(serviceIcons);
p.appendChild(document.createTextNode(' '));
}
// Movie name is now plain text for all users (no link)
const movieText = document.createElement('span');
movieText.textContent = 'Movie: ' + item.movieName;
p.appendChild(movieText);
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;
}
function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.dataset.label = label;
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'detail-value';
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
return item;
}
-227
View File
@@ -1,227 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import {
saveRequestTypes,
saveRequestStatuses,
saveRequestSort,
saveRequestSearch
} from '../utils/storage.js';
import { renderRequests } from './requests.js';
// ---- Type filter dropdown ----
function initTypeFilter() {
const btn = document.getElementById('request-type-filter-btn');
const dropdown = document.getElementById('request-type-filter-dropdown');
const selectAll = document.getElementById('request-type-select-all');
const deselectAll = document.getElementById('request-type-deselect-all');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
selectAll?.addEventListener('click', () => setAllTypes(true));
deselectAll?.addEventListener('click', () => setAllTypes(false));
// Wire up checkboxes
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
const value = cb.closest('.request-filter-option').dataset.value;
toggleType(value, cb.checked);
});
});
updateTypeFilterUI();
}
function setAllTypes(checked) {
const dropdown = document.getElementById('request-type-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
const newTypes = [];
checkboxes.forEach(cb => {
cb.checked = checked;
if (checked) newTypes.push(cb.closest('.request-filter-option').dataset.value);
});
state.selectedRequestTypes = checked ? newTypes : [];
saveRequestTypes(state.selectedRequestTypes);
updateTypeFilterUI();
renderRequests();
}
function toggleType(value, checked) {
const idx = state.selectedRequestTypes.indexOf(value);
if (checked && idx === -1) {
state.selectedRequestTypes.push(value);
} else if (!checked && idx > -1) {
state.selectedRequestTypes.splice(idx, 1);
}
saveRequestTypes(state.selectedRequestTypes);
updateTypeFilterUI();
renderRequests();
}
function updateTypeFilterUI() {
const text = document.getElementById('request-type-selected-text');
if (!text) return;
const dropdown = document.getElementById('request-type-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
const value = cb.closest('.request-filter-option').dataset.value;
cb.checked = state.selectedRequestTypes.includes(value);
});
if (state.selectedRequestTypes.length === 0) {
text.textContent = 'All';
} else if (state.selectedRequestTypes.length === checkboxes.length) {
text.textContent = 'All';
} else {
text.textContent = state.selectedRequestTypes.length;
}
}
// ---- Status filter dropdown ----
function initStatusFilter() {
const btn = document.getElementById('request-status-filter-btn');
const dropdown = document.getElementById('request-status-filter-dropdown');
const selectAll = document.getElementById('request-status-select-all');
const deselectAll = document.getElementById('request-status-deselect-all');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
selectAll?.addEventListener('click', () => setAllStatuses(true));
deselectAll?.addEventListener('click', () => setAllStatuses(false));
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
const value = cb.closest('.request-filter-option').dataset.value;
toggleStatus(value, cb.checked);
});
});
updateStatusFilterUI();
}
function setAllStatuses(checked) {
const dropdown = document.getElementById('request-status-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
const newStatuses = [];
checkboxes.forEach(cb => {
cb.checked = checked;
if (checked) newStatuses.push(cb.closest('.request-filter-option').dataset.value);
});
state.selectedRequestStatuses = checked ? newStatuses : [];
saveRequestStatuses(state.selectedRequestStatuses);
updateStatusFilterUI();
renderRequests();
}
function toggleStatus(value, checked) {
const idx = state.selectedRequestStatuses.indexOf(value);
if (checked && idx === -1) {
state.selectedRequestStatuses.push(value);
} else if (!checked && idx > -1) {
state.selectedRequestStatuses.splice(idx, 1);
}
saveRequestStatuses(state.selectedRequestStatuses);
updateStatusFilterUI();
renderRequests();
}
function updateStatusFilterUI() {
const text = document.getElementById('request-status-selected-text');
if (!text) return;
const dropdown = document.getElementById('request-status-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
const value = cb.closest('.request-filter-option').dataset.value;
cb.checked = state.selectedRequestStatuses.includes(value);
});
if (state.selectedRequestStatuses.length === 0) {
text.textContent = 'All';
} else if (state.selectedRequestStatuses.length === checkboxes.length) {
text.textContent = 'All';
} else {
text.textContent = state.selectedRequestStatuses.length;
}
}
// ---- Sort select ----
function initSortSelect() {
const select = document.getElementById('request-sort-select');
if (!select) return;
select.value = state.requestSortMode;
select.addEventListener('change', (e) => {
state.requestSortMode = e.target.value;
saveRequestSort(state.requestSortMode);
renderRequests();
});
}
// ---- Search input ----
function initSearchInput() {
const input = document.getElementById('request-search-input');
if (!input) return;
input.value = state.requestSearchQuery;
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
state.requestSearchQuery = e.target.value;
saveRequestSearch(state.requestSearchQuery);
renderRequests();
}, 200);
});
}
// ---- Global click-outside handler ----
function initClickOutside() {
document.addEventListener('click', (e) => {
const typeDropdown = document.getElementById('request-type-filter-dropdown');
const typeBtn = document.getElementById('request-type-filter-btn');
const statusDropdown = document.getElementById('request-status-filter-dropdown');
const statusBtn = document.getElementById('request-status-filter-btn');
if (typeDropdown && !typeDropdown.contains(e.target) && e.target !== typeBtn && !typeBtn?.contains(e.target)) {
typeDropdown.classList.remove('open');
}
if (statusDropdown && !statusDropdown.contains(e.target) && e.target !== statusBtn && !statusBtn?.contains(e.target)) {
statusDropdown.classList.remove('open');
}
});
}
// ---- Public API ----
export function initRequestFilters() {
initTypeFilter();
initStatusFilter();
initSortSelect();
initSearchInput();
initClickOutside();
// Listen for SSE updates (registered once on app bootstrap)
document.addEventListener('ombiRequestsUpdated', () => {
renderRequests();
});
}
-175
View File
@@ -1,175 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { escapeHtml } from '../utils/format.js';
import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
/**
* Helper function to extract the username from an Ombi request object.
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
* not a string, so we need to extract the username from the object.
*
* Must stay in sync with server/utils/ombiHelpers.js
*
* @param {Object} request - The Ombi request object
* @returns {string} The extracted username, or empty string if not found
*/
function extractRequestedUser(request) {
if (!request) return '';
// Handle object format: OmbiStore.Entities.OmbiUser
if (request.requestedUser && typeof request.requestedUser === 'object') {
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
return request.requestedUser.alias ||
request.requestedUser.userAlias ||
request.requestedUser.userName ||
request.requestedUser.normalizedUserName ||
request.requestedByAlias || '';
}
// Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || '';
}
export function renderRequests() {
const requestsList = document.getElementById('requests-list');
const noRequests = document.getElementById('no-requests');
if (!requestsList) return;
const ombiRequests = state.ombiRequests || { movie: [], tv: [] };
const allRequests = [
...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })),
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
];
// Apply client-side filters, sorting, and search
const filtered = applyRequestFilters(allRequests, {
types: state.selectedRequestTypes,
statuses: state.selectedRequestStatuses,
sort: state.requestSortMode,
search: state.requestSearchQuery
});
requestsList.innerHTML = '';
if (filtered.length === 0) {
if (noRequests) {
noRequests.style.display = 'block';
const p = noRequests.querySelector('p');
if (p) {
// Differentiate between no data from Ombi vs filters excluded everything
const hasAnyData = allRequests.length > 0;
p.textContent = hasAnyData
? 'No requests match your filters.'
: 'No requests found.';
}
}
return;
}
if (noRequests) noRequests.style.display = 'none';
filtered.forEach(request => {
const card = createRequestCard(request);
requestsList.appendChild(card);
});
}
function createRequestCard(request) {
if (!request) {
const card = document.createElement('div');
card.className = 'request-card';
card.textContent = 'Invalid request data';
return card;
}
const card = document.createElement('div');
card.className = 'request-card';
const typeIcon = document.createElement('span');
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺';
const content = document.createElement('div');
content.className = 'request-content';
const title = document.createElement('div');
title.className = 'request-title';
title.textContent = request.title || 'Unknown Title';
const meta = document.createElement('div');
meta.className = 'request-meta';
const statusBadge = createStatusBadge(request);
meta.appendChild(statusBadge);
if (request.year) {
const year = document.createElement('span');
year.className = 'request-year';
year.textContent = request.year;
meta.appendChild(year);
}
const username = extractRequestedUser(request);
if (username) {
const user = document.createElement('span');
user.className = 'request-user';
user.textContent = `Requested by: ${username}`;
meta.appendChild(user);
}
if (request.quality) {
const quality = document.createElement('span');
quality.className = 'request-quality';
quality.textContent = request.quality;
meta.appendChild(quality);
}
content.appendChild(title);
content.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'request-actions';
if (state.ombiBaseUrl && request.theMovieDbId) {
const ombiLink = document.createElement('a');
ombiLink.className = 'request-link ombi-link';
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
ombiLink.target = '_blank';
ombiLink.title = 'View in Ombi';
const ombiIcon = document.createElement('img');
ombiIcon.src = '/images/ombi.svg';
ombiIcon.alt = 'Ombi';
ombiIcon.className = 'request-icon';
ombiLink.appendChild(ombiIcon);
actions.appendChild(ombiLink);
}
card.appendChild(typeIcon);
card.appendChild(content);
card.appendChild(actions);
return card;
}
function createStatusBadge(request) {
const badge = document.createElement('span');
badge.className = 'request-status-badge';
const status = getRequestStatus(request);
const statusTexts = {
available: 'Available',
denied: `Denied: ${request.deniedReason || 'No reason'}`,
approved: 'Approved',
pending: 'Pending',
unknown: 'Unknown'
};
badge.classList.add(status);
badge.textContent = statusTexts[status] || 'Unknown';
return badge;
}
-203
View File
@@ -1,203 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, STATUS_REFRESH_MS } from '../state.js';
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
import { fetchWebhookStatus } from './webhooks.js';
export async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
const webhooksSection = document.getElementById('webhooks-section');
if (!panel.classList.contains('hidden')) {
// Close both panels (webhooks is a sibling, hide it too)
panel.classList.add('hidden');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
return;
}
// Open status panel and webhooks section (siblings)
panel.classList.remove('hidden');
// Show webhooks section for admin users (collapsed by default)
if (webhooksSection && state.isAdmin) {
webhooksSection.classList.remove('hidden');
state.webhookSectionExpanded = false;
document.getElementById('webhooks-content').classList.add('hidden');
document.getElementById('webhooks-toggle').classList.remove('expanded');
await fetchWebhookStatus();
} else if (webhooksSection) {
webhooksSection.classList.add('hidden');
}
refreshStatusPanel();
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
}
export function closeStatusPanel() {
document.getElementById('status-panel').classList.add('hidden');
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
}
export async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
const contentDiv = document.getElementById('status-content');
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
if (!panel || panel.classList.contains('hidden')) return;
console.log('[Status] Refreshing status panel...');
try {
const result = await apiRefreshStatusPanel();
if (result.success) {
console.log('[Status] Got status data, rendering...');
renderStatusPanel(result.data, panel);
} else {
console.error('[Status] API returned error:', result.error);
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + result.error + '</p>';
}
}
} catch (err) {
console.error('[Status] Error fetching status:', err);
// Don't overwrite panel on transient error during auto-refresh
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
}
}
}
export function renderStatusPanel(data, panel) {
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
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" id="status-close-btn">&times;</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>`;
// Webhook metrics card (admin only)
if (state.isAdmin && data.webhooks) {
const wh = data.webhooks;
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
const radarrEvents = wh.radarr?.eventsReceived || 0;
const ombiEvents = wh.ombi?.eventsReceived || 0;
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
const radarrPolls = wh.radarr?.pollsSkipped || 0;
const ombiPolls = wh.ombi?.pollsSkipped || 0;
html += `
<div class="status-card">
<div class="status-card-title">Webhooks</div>
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
</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>`;
// Render into status-content div, not the whole panel (preserves webhooks section)
const contentDiv = document.getElementById('status-content');
const panelCheck = document.getElementById('status-panel');
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
if (panelCheck) {
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
}
if (contentDiv) {
contentDiv.innerHTML = html;
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
} else {
console.error('[Status] contentDiv not found!');
}
// Wire close button — addEventListener avoids CSP inline handler restrictions
const closeBtn = document.getElementById('status-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
// 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 escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
-68
View File
@@ -1,68 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
import { loadHistory } from './history.js';
import { renderRequests } from './requests.js';
export function initTabs() {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const requestsTab = document.querySelector('[data-tab="requests"]');
const historyTab = document.querySelector('[data-tab="history"]');
if (!downloadsTab || !historyTab) return;
// Load saved tab
const savedTab = getActiveTab();
if (savedTab === 'requests') {
activateTab('requests');
} else if (savedTab === 'history') {
activateTab('history');
} else {
activateTab('downloads');
}
downloadsTab.addEventListener('click', () => activateTab('downloads'));
if (requestsTab) {
requestsTab.addEventListener('click', () => activateTab('requests'));
}
historyTab.addEventListener('click', () => activateTab('history'));
}
export function activateTab(tab) {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const requestsTab = document.querySelector('[data-tab="requests"]');
const historyTab = document.querySelector('[data-tab="history"]');
const downloadsSection = document.getElementById('tab-downloads');
const requestsSection = document.getElementById('tab-requests');
const historySection = document.getElementById('tab-history');
// Remove active class from all tabs
if (downloadsTab) downloadsTab.classList.remove('active');
if (requestsTab) requestsTab.classList.remove('active');
if (historyTab) historyTab.classList.remove('active');
// Hide all sections
if (downloadsSection) downloadsSection.classList.add('hidden');
if (requestsSection) requestsSection.classList.add('hidden');
if (historySection) historySection.classList.add('hidden');
if (tab === 'downloads') {
if (downloadsTab) downloadsTab.classList.add('active');
if (downloadsSection) downloadsSection.classList.remove('hidden');
saveActiveTab('downloads');
} else if (tab === 'requests') {
if (requestsTab) requestsTab.classList.add('active');
if (requestsSection) requestsSection.classList.remove('hidden');
saveActiveTab('requests');
renderRequests();
} else if (tab === 'history') {
if (historyTab) historyTab.classList.add('active');
if (historySection) historySection.classList.remove('hidden');
saveActiveTab('history');
loadHistory();
}
}
export function goHome() {
activateTab('downloads');
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { getTheme, saveTheme } from '../utils/storage.js';
// Apply saved theme immediately on load
(function applyTheme() {
const theme = getTheme();
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
})();
export function initThemeSwitcher() {
const themeToggle = document.getElementById('theme-toggle');
if (!themeToggle) return;
themeToggle.addEventListener('click', () => {
const currentTheme = getTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
saveTheme(theme);
}
-292
View File
@@ -1,292 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, enableOmbiWebhook as apiEnableOmbiWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../api.js';
import { formatTimeAgo } from '../utils/format.js';
export function initWebhooks() {
const webhooksSection = document.getElementById('webhooks-section');
if (!webhooksSection) return;
// Note: visibility is controlled by showDashboard() based on isAdmin
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
document.getElementById('enable-ombi-webhook').addEventListener('click', enableOmbiWebhook);
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
document.getElementById('test-ombi-webhook').addEventListener('click', testOmbiWebhook);
}
export function toggleWebhookSection() {
state.webhookSectionExpanded = !state.webhookSectionExpanded;
const content = document.getElementById('webhooks-content');
const toggle = document.getElementById('webhooks-toggle');
if (state.webhookSectionExpanded) {
content.classList.remove('hidden');
} else {
content.classList.add('hidden');
}
toggle.classList.toggle('expanded', state.webhookSectionExpanded);
if (state.webhookSectionExpanded) {
fetchWebhookStatus();
}
}
export async function fetchWebhookStatus() {
const loadingEl = document.getElementById('webhook-loading');
loadingEl.classList.remove('hidden');
try {
const result = await apiFetchWebhookStatus();
if (result.success) {
renderWebhookStatus();
}
} catch (err) {
console.error('Failed to fetch webhook status:', err);
} finally {
loadingEl.classList.add('hidden');
}
}
export function renderWebhookStatus() {
// Sonarr
const sonarrStatus = document.getElementById('sonarr-status');
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
const sonarrTriggers = document.getElementById('sonarr-triggers');
const sonarrStats = document.getElementById('sonarr-stats');
sonarrStatus.textContent = state.sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
sonarrStatus.className = 'status-indicator ' + (state.sonarrWebhook.enabled ? 'enabled' : 'disabled');
if (state.sonarrWebhook.enabled) {
sonarrEnableBtn.classList.add('hidden');
sonarrTestBtn.classList.remove('hidden');
sonarrTriggers.classList.remove('hidden');
} else {
sonarrEnableBtn.classList.remove('hidden');
sonarrTestBtn.classList.add('hidden');
sonarrTriggers.classList.add('hidden');
}
if (state.sonarrWebhook.enabled) {
document.getElementById('sonarr-onGrab').textContent = state.sonarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('sonarr-onDownload').textContent = state.sonarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('sonarr-onImport').textContent = state.sonarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('sonarr-onUpgrade').textContent = state.sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (state.sonarrWebhook.stats) {
sonarrStats.classList.remove('hidden');
document.getElementById('sonarr-events').textContent = state.sonarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('sonarr-polls').textContent = state.sonarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('sonarr-last').textContent = formatTimeAgo(state.sonarrWebhook.stats.lastWebhookTimestamp);
} else {
sonarrStats.classList.add('hidden');
}
// Radarr
const radarrStatus = document.getElementById('radarr-status');
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
const radarrTestBtn = document.getElementById('test-radarr-webhook');
const radarrTriggers = document.getElementById('radarr-triggers');
const radarrStats = document.getElementById('radarr-stats');
radarrStatus.textContent = state.radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
radarrStatus.className = 'status-indicator ' + (state.radarrWebhook.enabled ? 'enabled' : 'disabled');
if (state.radarrWebhook.enabled) {
radarrEnableBtn.classList.add('hidden');
radarrTestBtn.classList.remove('hidden');
radarrTriggers.classList.remove('hidden');
} else {
radarrEnableBtn.classList.remove('hidden');
radarrTestBtn.classList.add('hidden');
radarrTriggers.classList.add('hidden');
}
if (state.radarrWebhook.enabled) {
document.getElementById('radarr-onGrab').textContent = state.radarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (state.radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('radarr-onDownload').textContent = state.radarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (state.radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('radarr-onImport').textContent = state.radarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('radarr-onImport').className = 'trigger-value ' + (state.radarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('radarr-onUpgrade').textContent = state.radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (state.radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (state.radarrWebhook.stats) {
radarrStats.classList.remove('hidden');
document.getElementById('radarr-events').textContent = state.radarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('radarr-polls').textContent = state.radarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('radarr-last').textContent = formatTimeAgo(state.radarrWebhook.stats.lastWebhookTimestamp);
} else {
radarrStats.classList.add('hidden');
}
// Ombi
const ombiStatus = document.getElementById('ombi-status');
const ombiEnableBtn = document.getElementById('enable-ombi-webhook');
const ombiTestBtn = document.getElementById('test-ombi-webhook');
const ombiTriggers = document.getElementById('ombi-triggers');
const ombiStats = document.getElementById('ombi-stats');
ombiStatus.textContent = state.ombiWebhook.enabled ? '● Enabled' : '○ Disabled';
ombiStatus.className = 'status-indicator ' + (state.ombiWebhook.enabled ? 'enabled' : 'disabled');
if (state.ombiWebhook.enabled) {
ombiEnableBtn.classList.add('hidden');
ombiTestBtn.classList.remove('hidden');
ombiTriggers.classList.remove('hidden');
} else {
ombiEnableBtn.classList.remove('hidden');
ombiTestBtn.classList.add('hidden');
ombiTriggers.classList.add('hidden');
}
if (state.ombiWebhook.enabled) {
document.getElementById('ombi-requestAvailable').textContent = state.ombiWebhook.triggers.requestAvailable ? '✓' : '✗';
document.getElementById('ombi-requestAvailable').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestAvailable ? 'active' : 'inactive');
document.getElementById('ombi-requestApproved').textContent = state.ombiWebhook.triggers.requestApproved ? '✓' : '✗';
document.getElementById('ombi-requestApproved').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestApproved ? 'active' : 'inactive');
document.getElementById('ombi-requestDeclined').textContent = state.ombiWebhook.triggers.requestDeclined ? '✓' : '✗';
document.getElementById('ombi-requestDeclined').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestDeclined ? 'active' : 'inactive');
document.getElementById('ombi-requestPending').textContent = state.ombiWebhook.triggers.requestPending ? '✓' : '✗';
document.getElementById('ombi-requestPending').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestPending ? 'active' : 'inactive');
document.getElementById('ombi-requestProcessing').textContent = state.ombiWebhook.triggers.requestProcessing ? '✓' : '✗';
document.getElementById('ombi-requestProcessing').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestProcessing ? 'active' : 'inactive');
}
if (state.ombiWebhook.stats) {
ombiStats.classList.remove('hidden');
document.getElementById('ombi-events').textContent = state.ombiWebhook.stats.eventsReceived ?? 0;
document.getElementById('ombi-polls').textContent = state.ombiWebhook.stats.pollsSkipped ?? 0;
document.getElementById('ombi-last').textContent = formatTimeAgo(state.ombiWebhook.stats.lastWebhookTimestamp);
} else {
ombiStats.classList.add('hidden');
}
}
export async function enableSonarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableSonarrWebhook();
if (!result.success) {
console.error('Failed to enable Sonarr webhook:', result.error);
alert('Failed to enable Sonarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
alert('Failed to enable Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function enableRadarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableRadarrWebhook();
if (!result.success) {
console.error('Failed to enable Radarr webhook:', result.error);
alert('Failed to enable Radarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
alert('Failed to enable Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testSonarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestSonarrWebhook();
if (result.success) {
alert('Sonarr webhook test sent successfully!');
} else {
console.error('Failed to test Sonarr webhook:', result.error);
alert('Failed to test Sonarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
alert('Failed to test Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testRadarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestRadarrWebhook();
if (result.success) {
alert('Radarr webhook test sent successfully!');
} else {
console.error('Failed to test Radarr webhook:', result.error);
alert('Failed to test Radarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
alert('Failed to test Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function enableOmbiWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableOmbiWebhook();
if (!result.success) {
console.error('Failed to enable Ombi webhook:', result.error);
alert('Failed to enable Ombi webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Ombi webhook:', err);
alert('Failed to enable Ombi webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testOmbiWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestOmbiWebhook();
if (result.success) {
alert('Ombi webhook test sent successfully!');
} else {
console.error('Failed to test Ombi webhook:', result.error);
alert('Failed to test Ombi webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Ombi webhook:', err);
alert('Failed to test Ombi webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export function setWebhookLoading(loading) {
state.webhookLoading = loading;
document.getElementById('enable-sonarr-webhook').disabled = loading;
document.getElementById('enable-radarr-webhook').disabled = loading;
document.getElementById('enable-ombi-webhook').disabled = loading;
document.getElementById('test-sonarr-webhook').disabled = loading;
document.getElementById('test-radarr-webhook').disabled = loading;
document.getElementById('test-ombi-webhook').disabled = loading;
const loadingEl = document.getElementById('webhook-loading');
if (loading) {
loadingEl.classList.remove('hidden');
} else {
loadingEl.classList.add('hidden');
}
}
-74
View File
@@ -1,74 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
export function formatSize(size) {
if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is
if (typeof size === 'string') {
return size;
}
// If it's a number (bytes), format it
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(1024));
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export function formatSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
export function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
export function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
export function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 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.
export 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;
}
-107
View File
@@ -1,107 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Pure filter / sort / search utilities for Ombi requests.
* Must stay in sync with server/utils/ombiFilters.js
*/
/**
* Derive a single status string from an Ombi request object.
* Priority: available > denied > approved > pending > unknown
*
* @param {Object} request
* @returns {string}
*/
export function getRequestStatus(request) {
if (!request) return 'unknown';
if (request.available) return 'available';
if (request.denied) return 'denied';
if (request.approved) return 'approved';
if (request.requested) return 'pending';
return 'unknown';
}
/**
* Filter requests by media type.
*
* @param {Array} requests
* @param {string[]} types
* @returns {Array}
*/
export function filterByType(requests, types) {
if (!types || types.length === 0) return requests;
const normalized = types.map(t => t.toLowerCase());
if (normalized.includes('all')) return requests;
return requests.filter(r => normalized.includes(r.mediaType));
}
/**
* Filter requests by status.
*
* @param {Array} requests
* @param {string[]} statuses
* @returns {Array}
*/
export function filterByStatus(requests, statuses) {
if (!statuses || statuses.length === 0) return requests;
const normalized = statuses.map(s => s.toLowerCase());
return requests.filter(r => normalized.includes(getRequestStatus(r)));
}
/**
* Filter requests by case-insensitive title substring.
*
* @param {Array} requests
* @param {string} query
* @returns {Array}
*/
export function filterBySearch(requests, query) {
if (!query || query.trim() === '') return requests;
const q = query.trim().toLowerCase();
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
}
/**
* Sort requests by the given sort mode.
*
* @param {Array} requests
* @param {string} sortMode
* @returns {Array}
*/
export function sortRequests(requests, sortMode) {
const sorted = [...requests];
switch (sortMode) {
case 'requestedDate_asc':
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return da - db;
});
case 'title_asc':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'title_desc':
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
case 'requestedDate_desc':
default:
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return db - da;
});
}
}
/**
* Apply all filters and sorting in one call.
*
* @param {Array} requests
* @param {Object} options
* @returns {Array}
*/
export function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
let result = [...requests];
result = filterByType(result, types);
result = filterByStatus(result, statuses);
result = filterBySearch(result, search);
result = sortRequests(result, sort);
return result;
}
-127
View File
@@ -1,127 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
// Migration from old single-select to new multi-select format
(function migrateDownloadClientFilter() {
const oldSelection = localStorage.getItem('sofarr-download-client');
if (oldSelection && oldSelection !== 'all') {
try {
state.selectedDownloadClients = [oldSelection];
localStorage.setItem('sofarr-download-clients', JSON.stringify(state.selectedDownloadClients));
localStorage.removeItem('sofarr-download-client');
} catch (e) {
console.error('[Migration] Failed to migrate download client filter:', e);
}
} else {
try {
const newSelection = localStorage.getItem('sofarr-download-clients');
state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
} catch (e) {
console.error('[Migration] Failed to load download client filter:', e);
state.selectedDownloadClients = [];
}
}
})();
// Load history days from localStorage
(function loadHistorySettings() {
try {
const savedDays = localStorage.getItem('sofarr-history-days');
if (savedDays) {
state.historyDays = parseInt(savedDays, 10) || 7;
}
} catch (e) {
console.error('[Storage] Failed to load history days:', e);
}
})();
// Load ignore available setting from localStorage
(function loadIgnoreAvailable() {
try {
const saved = localStorage.getItem('sofarr-ignore-available');
state.ignoreAvailable = saved === 'true';
} catch (e) {
console.error('[Storage] Failed to load ignore available:', e);
}
})();
// Load request filter preferences from localStorage
(function loadRequestFilters() {
try {
const savedTypes = localStorage.getItem('sofarr-request-types');
if (savedTypes) state.selectedRequestTypes = JSON.parse(savedTypes);
} catch (e) {
console.error('[Storage] Failed to load request types:', e);
state.selectedRequestTypes = ['movie', 'tv'];
}
try {
const savedStatuses = localStorage.getItem('sofarr-request-statuses');
if (savedStatuses) state.selectedRequestStatuses = JSON.parse(savedStatuses);
} catch (e) {
console.error('[Storage] Failed to load request statuses:', e);
state.selectedRequestStatuses = [];
}
try {
const savedSort = localStorage.getItem('sofarr-request-sort');
if (savedSort) state.requestSortMode = savedSort;
} catch (e) {
console.error('[Storage] Failed to load request sort:', e);
state.requestSortMode = 'requestedDate_desc';
}
try {
const savedSearch = localStorage.getItem('sofarr-request-search');
if (savedSearch !== null) state.requestSearchQuery = savedSearch;
} catch (e) {
console.error('[Storage] Failed to load request search:', e);
state.requestSearchQuery = '';
}
})();
// Export helper functions for localStorage operations
export function saveHistoryDays(days) {
localStorage.setItem('sofarr-history-days', days);
}
export function saveIgnoreAvailable(value) {
localStorage.setItem('sofarr-ignore-available', value);
}
export function saveDownloadClients(clients) {
localStorage.setItem('sofarr-download-clients', JSON.stringify(clients));
}
export function getTheme() {
return localStorage.getItem('sofarr-theme') || 'light';
}
export function saveTheme(theme) {
localStorage.setItem('sofarr-theme', theme);
}
export function getActiveTab() {
return localStorage.getItem('sofarr-active-tab') || 'downloads';
}
export function saveActiveTab(tab) {
localStorage.setItem('sofarr-active-tab', tab);
}
export function saveRequestTypes(types) {
localStorage.setItem('sofarr-request-types', JSON.stringify(types));
}
export function saveRequestStatuses(statuses) {
localStorage.setItem('sofarr-request-statuses', JSON.stringify(statuses));
}
export function saveRequestSort(sort) {
localStorage.setItem('sofarr-request-sort', sort);
}
export function saveRequestSearch(query) {
localStorage.setItem('sofarr-request-search', query);
}
+2 -15
View File
@@ -1,21 +1,8 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
build: {
outDir: '../public',
emptyOutDir: false,
rollupOptions: {
input: {
main: './src/main.js'
},
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
},
plugins: [react()],
server: {
port: 5173,
proxy: {
+2 -67
View File
@@ -1,82 +1,17 @@
version: "3"
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 code for development (comment out in production)
- ./server:/app/server
- ./public:/app/public
# 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
# Comment out for development when mounting code volumes
# 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
- LOG_LEVEL=info
-399
View File
@@ -1,399 +0,0 @@
# 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.*
+609
View File
@@ -0,0 +1,609 @@
# sofarr — Architecture Documentation
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Technology Stack](#2-technology-stack)
3. [Directory Structure](#3-directory-structure)
4. [Component Architecture](#4-component-architecture)
5. [Data Flow](#5-data-flow)
6. [Authentication & Authorisation](#6-authentication--authorisation)
7. [Background Polling & Caching](#7-background-polling--caching)
8. [Download Matching Pipeline](#8-download-matching-pipeline)
9. [API Reference](#9-api-reference)
10. [Frontend Architecture](#10-frontend-architecture)
11. [Configuration](#11-configuration)
12. [Deployment](#12-deployment)
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
---
## 1. System Overview
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
1. **Authenticating** users against an Emby/Jellyfin media server.
2. **Aggregating** download data from multiple *arr service instances and download clients.
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
### High-Level Architecture
```
┌─────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Login │ │Dashboard │ │ Status Panel │ │
│ │ Form │ │ Cards │ │ (Admin only) │ │
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ POST /login │ GET /user- │ GET /status
│ │ downloads │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Express Server (:3001) │
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
│ │ │ │ │
│ ┌────┴──────────┴────────────┴──────────────────┐ │
│ │ Utilities Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
│ └──────┼────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────┘
│ HTTP/API calls
┌──────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Emby / Jellyfin │ │
│ │ (Authentication + User DB) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
---
## 2. Technology Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Runtime** | Node.js 18+ | Server runtime |
| **Framework** | Express 4.x | HTTP server, routing, middleware |
| **HTTP Client** | axios 1.x | External API communication |
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
| **Auth** | Emby API + httpOnly cookies | Session management |
| **Caching** | In-memory Map with TTL | Reduce external API load |
| **Scheduling** | `setInterval` | Background polling |
| **Containerisation** | Docker (Alpine) | Production deployment |
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
---
## 3. Directory Structure
```
sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: Express setup, middleware, startup
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
│ │ ├── emby.js # Proxy routes to Emby API
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ └── radarr.js # Proxy routes to Radarr API
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── logger.js # File logger (server.log)
│ ├── poller.js # Background polling engine + timing
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
├── public/ # Static frontend (served by Express)
│ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── style.css # Themes, layout, responsive design
│ └── images/ # Logo / splash screen assets
├── Dockerfile # Production container image
├── docker-compose.yaml # Example compose deployment
├── package.json # Dependencies and scripts
├── .env.sample # Annotated environment variable template
└── README.md # User-facing documentation
```
---
## 4. Component Architecture
### 4.1 Server Entry Point (`server/index.js`)
Responsibilities:
- Load environment variables via `dotenv`
- Configure structured logging with level filtering (`LOG_LEVEL`)
- Redirect `console.*` to both stdout and `server.log`
- Mount Express middleware (CORS, cookie-parser, JSON, static files)
- Mount route modules under `/api/*`
- Start the background poller
### 4.2 Route Modules
| Module | Mount Point | Auth Required | Purpose |
|--------|------------|---------------|---------|
| `auth.js` | `/api/auth` | No | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status |
| `emby.js` | `/api/emby` | No | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API |
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
### 4.3 Utility Modules
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
---
## 5. Data Flow
### 5.1 Polling Cycle
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
| Task | API Call | Params |
|------|----------|--------|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
| Sonarr Tags | `GET /api/v3/tag` | — |
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
### 5.2 Dashboard Request
When a user requests `/api/dashboard/user-downloads`:
1. Read all `poll:*` keys from cache
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
8. Return only the user's downloads (or all, if admin with `showAll=true`)
---
## 6. Authentication & Authorisation
### Flow
1. User submits credentials via the login form
2. Backend calls Emby `POST /Users/authenticatebyname`
3. On success, fetches full user profile to determine admin status
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }`
5. Cookie expires after 24 hours
6. All subsequent dashboard requests read this cookie for identity
### Authorisation Matrix
| Feature | Regular User | Admin |
|---------|:----------:|:-----:|
| View own downloads | ✓ | ✓ |
| View all users' downloads | ✗ | ✓ (`showAll`) |
| See download/target paths | ✗ | ✓ |
| See Sonarr/Radarr links | ✗ | ✓ |
| View status panel | ✗ | ✓ |
### Tag Matching
Users are matched to downloads via tags in Sonarr/Radarr:
1. **Exact match**: tag label (lowercased) === username (lowercased)
2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
---
## 7. Background Polling & Caching
### Polling Modes
| Mode | `POLL_INTERVAL` | Behaviour |
|------|----------------|-----------|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms |
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty |
### Cache Keys
| Key | Content | Source |
|-----|---------|--------|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue |
| `poll:sab-history` | `{ slots }` | SABnzbd history |
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API |
| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) |
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history |
| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) |
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
### TTL Strategy
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
### Active Client Tracking
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
---
## 8. Download Matching Pipeline
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
### Matching Strategy
For each download item (SABnzbd slot or qBittorrent torrent):
```
1. Try Sonarr QUEUE match (by title substring)
→ resolve series via seriesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
2. Try Radarr QUEUE match (by title substring)
→ resolve movie via moviesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
3. Try Sonarr HISTORY match (by title substring)
→ resolve series via seriesMap (from queue) using seriesId
→ extract user tag → check tag matches requesting user
4. Try Radarr HISTORY match (by title substring)
→ resolve movie via moviesMap (from queue) using movieId
→ extract user tag → check tag matches requesting user
```
### Title Matching
Matches are **bidirectional substring matches** (case-insensitive):
```javascript
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
```
### Download Object Structure
Each matched download produces an object with:
| Field | Type | Description |
|-------|------|-------------|
| `type` | `'series'` / `'movie'` / `'torrent'` | Media type |
| `title` | string | Raw download title |
| `coverArt` | string / null | Poster URL from *arr |
| `status` | string | Download status |
| `progress` | string | Percentage complete |
| `size` / `mb` / `mbmissing` | string / number | Size info |
| `speed` | string | Current download speed |
| `eta` | string | Estimated time remaining |
| `seriesName` / `movieName` | string | Friendly media title |
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
| `userTag` | string | Matched user tag |
| `importIssues` | string[] / null | Import warning/error messages |
| `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path |
| `arrLink` | string / null | (Admin) Link to *arr web UI |
---
## 9. API Reference
### `POST /api/auth/login`
Authenticate a user via Emby.
**Request Body:**
```json
{ "username": "string", "password": "string" }
```
**Response (200):**
```json
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": true }
}
```
**Response (401):**
```json
{ "success": false, "error": "Invalid username or password" }
```
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL).
---
### `GET /api/auth/me`
Check current session.
**Response:**
```json
{
"authenticated": true,
"user": { "id": "string", "name": "string", "isAdmin": false }
}
```
---
### `POST /api/auth/logout`
Clear session cookie.
---
### `GET /api/dashboard/user-downloads`
Fetch downloads for the authenticated user.
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
**Response (200):**
```json
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
```
---
### `GET /api/dashboard/status`
Admin-only server status.
**Response (200):**
```json
{
"server": {
"uptimeSeconds": 3600,
"nodeVersion": "v18.19.0",
"memoryUsageMB": 45.2,
"heapUsedMB": 28.1,
"heapTotalMB": 35.0
},
"polling": {
"enabled": true,
"intervalMs": 5000,
"lastPoll": {
"totalMs": 1234,
"timestamp": "2026-05-16T00:00:00.000Z",
"tasks": [
{ "label": "SABnzbd Queue", "ms": 120 },
{ "label": "Sonarr Queue", "ms": 890 }
]
}
},
"cache": {
"entryCount": 9,
"totalSizeBytes": 51200,
"entries": [
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
]
},
"clients": [
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
]
}
```
---
### `GET /api/dashboard/user-summary`
Admin-only per-user download counts (fetches live from APIs, not cached).
**Response (200):**
```json
[
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
]
```
---
## 10. Frontend Architecture
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`.
### UI States
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
│ (on load) │ │ (if no │ │ (after auth) │
│ │ │ session) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
┌─────┴─────┐
│ Status │
│ Panel │
│ (admin) │
└───────────┘
```
### Key Frontend Functions
| Function | Purpose |
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
### Themes
Three CSS themes via `data-theme` attribute on `<html>`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
Theme selection persists in `localStorage`.
### Auto-Refresh
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
---
## 11. Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
| `EMBY_API_KEY` | Yes | — | Emby API key |
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
### Instance JSON Format
```json
[
{
"name": "main",
"url": "https://sonarr.example.com",
"apiKey": "your-api-key"
},
{
"name": "4k",
"url": "https://sonarr4k.example.com",
"apiKey": "your-4k-api-key"
}
]
```
qBittorrent instances use `username` and `password` instead of `apiKey`.
---
## 12. Deployment
### Docker
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
```
### Docker Compose
```yaml
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
- POLL_INTERVAL=5000
- LOG_LEVEL=info
```
---
## 13. UML Diagrams (PlantUML)
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
### 13.1 Component Diagram
See [`diagrams/component.puml`](diagrams/component.puml)
### 13.2 Sequence Diagrams
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
### 13.3 Class / Entity Diagrams
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
### 13.4 State Diagrams
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
### 13.5 Activity Diagram
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
+128
View File
@@ -0,0 +1,128 @@
@startuml activity-matching
!theme plain
title sofarr — Download Matching Activity Diagram
start
:Read cached data from MemoryCache;
note right
poll:sab-queue, poll:sab-history,
poll:sonarr-queue, poll:sonarr-history,
poll:radarr-queue, poll:radarr-history,
poll:sonarr-tags, poll:radarr-tags,
poll:qbittorrent
end note
:Build **seriesMap** from Sonarr queue records
(seriesId → embedded series object);
:Build **moviesMap** from Radarr queue records
(movieId → embedded movie object);
:Build **sonarrTagMap** (tagId → label)
Build **radarrTagMap** (tagId → label);
:Initialise **userDownloads** = [];
partition "Process SABnzbd Queue Slots" {
while (More queue slots?) is (yes)
:Get slot filename (nzbName);
:nzbNameLower = nzbName.toLowerCase();
if (Title matches Sonarr **queue** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series exists?) then (yes)
:userTag = extractUserTag(series.tags, sonarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=series)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
endif
endif
endif
if (Title matches Radarr **queue** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie exists?) then (yes)
:userTag = extractUserTag(movie.tags, radarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=movie)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
endif
endif
endif
endwhile (no)
}
partition "Process SABnzbd History Slots" {
while (More history slots?) is (yes)
:Get slot name (nzbName);
:nzbNameLower = nzbName.toLowerCase();
if (Title matches Sonarr **history** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series found?) then (yes)
:Check user tag, build download\n(type=series, with completedAt);
:Push to **userDownloads** if tag matches;
endif
endif
if (Title matches Radarr **history** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie found?) then (yes)
:Check user tag, build download\n(type=movie, with completedAt);
:Push to **userDownloads** if tag matches;
endif
endif
endwhile (no)
}
partition "Process qBittorrent Torrents" {
while (More torrents?) is (yes)
:Get torrent name;
:torrentNameLower = name.toLowerCase();
if (Matches Sonarr **queue**?) then (yes)
:Resolve series → check tag;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Radarr **queue**?) then (yes)
:Resolve movie → check tag;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Sonarr **history**?) then (yes)
:Resolve series via seriesMap;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Radarr **history**?) then (yes)
:Resolve movie via moviesMap;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
else (no match)
:Skip torrent (unmatched);
endif
endwhile (no)
}
:Return JSON response
{ user, isAdmin, downloads: userDownloads };
stop
legend right
**Title Matching Logic**
(bidirectional substring, case-insensitive):
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
**Tag Matching Logic**:
1. Exact: tag.toLowerCase() === username
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
(handles Ombi-mangled email-style usernames)
end legend
@enduml
+221
View File
@@ -0,0 +1,221 @@
@startuml class-data
!theme plain
title sofarr — Data Model Diagram
skinparam classAttributeIconSize 0
package "External API Responses" {
class "SABnzbd Queue Slot" as sabq {
+ filename : string
+ nzbname : string
+ percentage : string
+ mb : string
+ mbmissing : string
+ size : string
+ timeleft : string
+ status : string
+ storage : string
}
class "SABnzbd History Slot" as sabh {
+ name : string
+ nzb_name : string
+ nzbname : string
+ status : string
+ size : string
+ completed_time : string
+ storage : string
}
class "Sonarr Queue Record" as sqr {
+ id : number
+ seriesId : number
+ series : SonarrSeries
+ title : string
+ sourceTitle : string
+ trackedDownloadStatus : string
+ trackedDownloadState : string
+ statusMessages : StatusMessage[]
+ errorMessage : string
}
class "Sonarr History Record" as shr {
+ id : number
+ seriesId : number
+ title : string
+ sourceTitle : string
+ eventType : string
}
class "SonarrSeries" as ss {
+ id : number
+ title : string
+ titleSlug : string
+ path : string
+ tags : number[]
+ images : Image[]
+ _instanceUrl : string
}
class "Radarr Queue Record" as rqr {
+ id : number
+ movieId : number
+ movie : RadarrMovie
+ title : string
+ sourceTitle : string
+ trackedDownloadStatus : string
+ trackedDownloadState : string
+ statusMessages : StatusMessage[]
+ errorMessage : string
}
class "Radarr History Record" as rhr {
+ id : number
+ movieId : number
+ title : string
+ sourceTitle : string
+ eventType : string
}
class "RadarrMovie" as rm {
+ id : number
+ title : string
+ titleSlug : string
+ path : string
+ tags : number[]
+ images : Image[]
+ _instanceUrl : string
}
class "Tag" as tag {
+ id : number
+ label : string
}
class "Image" as img {
+ coverType : string
+ remoteUrl : string
+ url : string
}
class "StatusMessage" as sm {
+ title : string
+ messages : string[]
}
class "qBittorrent Torrent" as qbt {
+ name : string
+ hash : string
+ size : number
+ completed : number
+ progress : number (0-1)
+ state : string
+ dlspeed : number
+ eta : number
+ num_seeds : number
+ num_leechs : number
+ availability : number
+ category : string
+ tags : string
+ save_path : string
+ content_path : string
+ instanceId : string
+ instanceName : string
}
class "Emby User" as eu {
+ Id : string
+ Name : string
+ Policy : { IsAdministrator: boolean }
}
sqr *-- ss : embedded\n(includeSeries)
rqr *-- rm : embedded\n(includeMovie)
sqr *-- sm
rqr *-- sm
ss *-- img
rm *-- img
}
package "sofarr Internal Models" {
class "Download Object" as dl {
+ type : 'series' | 'movie' | 'torrent'
+ title : string
+ coverArt : string | null
+ status : string
+ progress : string
+ mb : string
+ mbmissing : string
+ size : string
+ speed : string
+ eta : string
+ seriesName : string | null
+ movieName : string | null
+ episodeInfo : object | null
+ movieInfo : object | null
+ userTag : string
+ importIssues : string[] | null
+ downloadPath : string | null
+ targetPath : string | null
+ arrLink : string | null
+ qbittorrent : boolean
+ seeds : number
+ peers : number
+ availability : string
+ rawSize : number
+ rawSpeed : number
+ rawEta : number
+ hash : string
+ category : string
+ completedAt : string
}
class "API Response\n/user-downloads" as apir {
+ user : string
+ isAdmin : boolean
+ downloads : Download[]
}
class "Status Response\n/status" as statr {
+ server : ServerInfo
+ polling : PollingInfo
+ cache : CacheStats
+ clients : ClientInfo[]
}
class "ServerInfo" as si {
+ uptimeSeconds : number
+ nodeVersion : string
+ memoryUsageMB : number
+ heapUsedMB : number
+ heapTotalMB : number
}
class "PollingInfo" as pi {
+ enabled : boolean
+ intervalMs : number
+ lastPoll : PollTimings
}
class "Session Cookie\nemby_user" as cookie {
+ id : string
+ name : string
+ isAdmin : boolean
+ token : string
}
apir *-- dl
statr *-- si
statr *-- pi
}
' Data flow connections
sabq ..> dl : matched &\ntransformed
sabh ..> dl : matched &\ntransformed
qbt ..> dl : mapTorrentToDownload()
ss ..> dl : coverArt, seriesName,\npath, tags
rm ..> dl : coverArt, movieName,\npath, tags
tag ..> dl : userTag resolution
eu ..> cookie : login creates
@enduml
+197
View File
@@ -0,0 +1,197 @@
@startuml class-server
!theme plain
title sofarr — Server Class / Module Diagram
package "server/index.js" as entry {
class "EntryPoint" as ep <<module>> {
- LOG_LEVELS : Object
- currentLevel : number
- logFile : WriteStream
+ shouldLog(level) : boolean
--
Configures Express app,
mounts routes, starts poller
}
}
package "server/routes" {
class "auth.js" as auth <<router>> {
+ POST /login
+ GET /me
+ POST /logout
--
Authenticates via Emby API
Sets/reads httpOnly cookie
}
class "dashboard.js" as dashboard <<router>> {
- activeClients : Map<string, ClientInfo>
- CLIENT_STALE_MS : 30000
--
+ GET /user-downloads
+ GET /user-summary
+ GET /status
--
- getCoverArt(item) : string|null
- extractUserTag(tags, tagMap) : string|null
- sanitizeTagLabel(input) : string
- tagMatchesUser(tag, username) : boolean
- getImportIssues(record) : string[]|null
- getSonarrLink(series) : string|null
- getRadarrLink(movie) : string|null
- getActiveClients() : ClientInfo[]
}
class "emby.js" as emby_r <<router>> {
+ GET /sessions
+ GET /users/:id
+ GET /users
+ GET /session/:sessionId/user
}
class "sabnzbd.js" as sab_r <<router>> {
+ GET /queue
+ GET /history
}
class "sonarr.js" as sonarr_r <<router>> {
+ GET /queue
+ GET /history
+ GET /series/:id
+ GET /series
}
class "radarr.js" as radarr_r <<router>> {
+ GET /queue
+ GET /history
+ GET /movies/:id
+ GET /movies
}
}
package "server/utils" {
class "MemoryCache" as cache {
- store : Map<string, CacheEntry>
+ get(key) : any|null
+ set(key, value, ttlMs) : void
+ invalidate(key) : void
+ clear() : void
+ getStats() : CacheStats
}
class "CacheEntry" as ce <<value>> {
+ value : any
+ expiresAt : number
}
class "CacheStats" as cs <<value>> {
+ entryCount : number
+ totalSizeBytes : number
+ entries : CacheEntryStats[]
}
class "Poller" as poller <<module>> {
- POLL_INTERVAL : number
- POLLING_ENABLED : boolean
- polling : boolean
- lastPollTimings : PollTimings|null
- intervalHandle : number|null
--
+ startPoller() : void
+ stopPoller() : void
+ pollAllServices() : Promise<void>
+ getLastPollTimings() : PollTimings|null
--
- timed(label, fn) : TimedResult
}
class "PollTimings" as pt <<value>> {
+ totalMs : number
+ timestamp : string (ISO)
+ tasks : { label, ms }[]
}
class "Config" as config <<module>> {
+ getSABnzbdInstances() : Instance[]
+ getSonarrInstances() : Instance[]
+ getRadarrInstances() : Instance[]
+ getQbittorrentInstances() : Instance[]
--
- parseInstances(envVar, ...) : Instance[]
}
class "Instance" as inst <<value>> {
+ id : string
+ name : string
+ url : string
+ apiKey : string
+ username? : string
+ password? : string
}
class "QBittorrentClient" as qbt {
- id : string
- name : string
- url : string
- username : string
- password : string
- authCookie : string|null
--
+ login() : Promise<boolean>
+ makeRequest(endpoint, config) : Promise<Response>
+ getTorrents() : Promise<Torrent[]>
}
class "qbittorrent.js" as qbt_mod <<module>> {
- persistedClients : QBittorrentClient[]|null
--
+ getTorrents() : Promise<Torrent[]>
+ getClients() : QBittorrentClient[]
+ mapTorrentToDownload(torrent) : Download
+ formatBytes(bytes) : string
+ formatSpeed(bps) : string
+ formatEta(seconds) : string
}
class "Logger" as logger <<module>> {
- logFile : WriteStream
+ logToFile(message) : void
}
class "ClientInfo" as ci <<value>> {
+ user : string
+ refreshRateMs : number
+ lastSeen : number (timestamp)
}
}
' Relationships
ep --> auth
ep --> dashboard
ep --> emby_r
ep --> sab_r
ep --> sonarr_r
ep --> radarr_r
ep --> poller : startPoller()
dashboard --> cache : read/write
dashboard --> poller : pollAllServices()
dashboard --> qbt_mod : mapTorrentToDownload()
dashboard --> config
poller --> cache : set poll:* keys
poller --> config : get instances
poller --> qbt_mod : getTorrents()
qbt_mod --> config : getQbittorrentInstances()
qbt_mod *-- qbt : creates
qbt --> logger
cache *-- ce : stores
cache ..> cs : returns from getStats()
poller ..> pt : stores/returns
dashboard *-- ci : stores in activeClients
config ..> inst : returns
@enduml
+94
View File
@@ -0,0 +1,94 @@
@startuml component
!theme plain
title sofarr — Component Diagram
skinparam componentStyle rectangle
skinparam packageStyle frame
package "Browser" as browser {
[index.html] as html
[app.js] as appjs
[style.css] as css
html ..> appjs : loads
html ..> css : loads
}
package "Express Server" as server {
package "Middleware" {
[CORS] as cors
[cookie-parser] as cp
[express.json] as ej
[express.static] as es
}
package "Routes" as routes {
[auth.js\n/api/auth] as auth
[dashboard.js\n/api/dashboard] as dashboard
[emby.js\n/api/emby] as emby_route
[sabnzbd.js\n/api/sabnzbd] as sab_route
[sonarr.js\n/api/sonarr] as sonarr_route
[radarr.js\n/api/radarr] as radarr_route
}
package "Utilities" as utils {
[poller.js] as poller
[cache.js\nMemoryCache] as cache
[config.js] as config
[qbittorrent.js\nQBittorrentClient] as qbt
[logger.js] as logger
}
[index.js\nEntry Point] as entry
entry --> cors
entry --> cp
entry --> ej
entry --> es
entry --> auth
entry --> dashboard
entry --> emby_route
entry --> sab_route
entry --> sonarr_route
entry --> radarr_route
entry --> poller : startPoller()
dashboard --> cache : read poll:* keys
dashboard --> poller : pollAllServices()\n(on-demand mode)
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
dashboard --> qbt : mapTorrentToDownload()
poller --> cache : set poll:* keys
poller --> config : get all instances
poller --> qbt : getTorrents()
poller --> logger
qbt --> config : getQbittorrentInstances()
qbt --> logger
}
cloud "External Services" as external {
[Emby / Jellyfin] as emby
[SABnzbd] as sab
[Sonarr] as sonarr
[Radarr] as radarr
[qBittorrent] as qbit
}
auth --> emby : authenticate\nuser profile
dashboard ..> emby : /user-summary\n(live fetch)
emby_route --> emby
sab_route --> sab
sonarr_route --> sonarr
radarr_route --> radarr
poller --> sab : queue + history
poller --> sonarr : tags + queue + history
poller --> radarr : tags + queue + history
qbt --> qbit : login + torrents/info
appjs --> auth : POST /login\nGET /me
appjs --> dashboard : GET /user-downloads\nGET /status
es --> html : serve static
@enduml
+67
View File
@@ -0,0 +1,67 @@
@startuml seq-auth
!theme plain
title sofarr — Authentication Sequence
actor User as user
participant "Browser\n(app.js)" as browser
participant "Express\n/api/auth" as auth
participant "Emby\nServer" as emby
== Page Load ==
user -> browser : Navigate to sofarr
activate browser
browser -> auth : GET /api/auth/me
activate auth
auth -> auth : Read emby_user cookie
alt Cookie exists and valid
auth --> browser : { authenticated: true, user: { name, isAdmin } }
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else No cookie
auth --> browser : { authenticated: false }
browser -> browser : dismissSplash()
browser -> browser : showLogin()
end
deactivate auth
== Login ==
user -> browser : Enter username + password
browser -> auth : POST /api/auth/login\n{ username, password }
activate auth
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
activate emby
alt Valid credentials
emby --> auth : { User: { Id, ... }, AccessToken }
auth -> emby : GET /Users/{userId}
emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL)
auth --> browser : { success: true, user: { name, isAdmin } }
browser -> browser : fadeOutLogin()
browser -> browser : showSplash()
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else Invalid credentials
emby --> auth : 401 Error
deactivate emby
auth --> browser : { success: false, error: "Invalid..." }
browser -> browser : showLoginError()
end
deactivate auth
== Logout ==
user -> browser : Click Logout
browser -> browser : stopAutoRefresh()
browser -> auth : POST /api/auth/logout
activate auth
auth -> auth : Clear emby_user cookie
auth --> browser : { success: true }
deactivate auth
browser -> browser : showLogin()
deactivate browser
@enduml
+85
View File
@@ -0,0 +1,85 @@
@startuml seq-dashboard
!theme plain
title sofarr — Dashboard Request Sequence
actor User as user
participant "Browser\n(app.js)" as browser
participant "Express\n/api/dashboard" as dashboard
participant "MemoryCache" as cache
participant "Poller" as poller
participant "External\nServices" as ext
== Periodic Refresh (or Initial Load) ==
user -> browser : (auto-refresh fires)
activate browser
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
activate dashboard
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
alt Polling disabled AND cache empty
dashboard -> poller : pollAllServices()
activate poller
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
ext --> poller : Raw data
poller -> cache : set poll:* keys\n(TTL = 30s)
deactivate poller
end
dashboard -> cache : get('poll:sab-queue')
cache --> dashboard : { slots, status, speed }
dashboard -> cache : get('poll:sab-history')
cache --> dashboard : { slots }
dashboard -> cache : get('poll:sonarr-tags')
cache --> dashboard : [{ instance, data }]
dashboard -> cache : get('poll:sonarr-queue')
cache --> dashboard : { records } (with embedded series)
dashboard -> cache : get('poll:sonarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-queue')
cache --> dashboard : { records } (with embedded movie)
dashboard -> cache : get('poll:radarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-tags')
cache --> dashboard : [{id, label}]
dashboard -> cache : get('poll:qbittorrent')
cache --> dashboard : [torrent, ...]
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
dashboard -> dashboard : Build tag maps\n(id → label)
group SABnzbd Queue Matching
loop each queue slot
dashboard -> dashboard : Match title vs Sonarr queue
dashboard -> dashboard : Match title vs Radarr queue
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username
end
end
group SABnzbd History Matching
loop each history slot
dashboard -> dashboard : Match title vs Sonarr history
dashboard -> dashboard : Match title vs Radarr history
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
end
end
group qBittorrent Matching
loop each torrent
dashboard -> dashboard : 1. Match vs Sonarr queue
dashboard -> dashboard : 2. Match vs Radarr queue
dashboard -> dashboard : 3. Match vs Sonarr history
dashboard -> dashboard : 4. Match vs Radarr history
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info
end
end
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
deactivate dashboard
browser -> browser : renderDownloads()\n(diff-based update)
deactivate browser
@enduml
+89
View File
@@ -0,0 +1,89 @@
@startuml seq-polling
!theme plain
title sofarr — Background Polling Cycle
participant "index.js\n(startup)" as entry
participant "Poller" as poller
participant "Config" as config
participant "SABnzbd\n(per instance)" as sab
participant "Sonarr\n(per instance)" as sonarr
participant "Radarr\n(per instance)" as radarr
participant "qBittorrent\nClient" as qbt
participant "MemoryCache" as cache
== Startup ==
entry -> poller : startPoller()
activate poller
alt POLL_INTERVAL > 0
poller -> poller : pollAllServices() (immediate)
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
else POLL_INTERVAL = 0
poller --> entry : "Polling disabled, on-demand mode"
end
== Poll Cycle ==
poller -> poller : Check: polling flag?\n(skip if concurrent)
poller -> poller : polling = true
poller -> poller : start = Date.now()
poller -> config : getSABnzbdInstances()
config --> poller : [{ id, url, apiKey }]
poller -> config : getSonarrInstances()
config --> poller : [{ id, url, apiKey }]
poller -> config : getRadarrInstances()
config --> poller : [{ id, url, apiKey }]
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
par SABnzbd Queue
poller -> sab : GET /api?mode=queue
sab --> poller : { queue: { slots, status, speed } }
and SABnzbd History
poller -> sab : GET /api?mode=history&limit=10
sab --> poller : { history: { slots } }
and Sonarr Tags
poller -> sonarr : GET /api/v3/tag
sonarr --> poller : [{ id, label }]
and Sonarr Queue
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
sonarr --> poller : { records: [{ seriesId, series, ... }] }
and Sonarr History
poller -> sonarr : GET /api/v3/history\n?pageSize=10
sonarr --> poller : { records: [{ seriesId, ... }] }
and Radarr Queue
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
radarr --> poller : { records: [{ movieId, movie, ... }] }
and Radarr History
poller -> radarr : GET /api/v3/history\n?pageSize=10
radarr --> poller : { records: [{ movieId, ... }] }
and Radarr Tags
poller -> radarr : GET /api/v3/tag
radarr --> poller : [{ id, label }]
and qBittorrent
poller -> qbt : getTorrents()
qbt --> poller : [{ name, progress, ... }]
end
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
poller -> poller : cacheTTL = POLL_INTERVAL × 3
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
poller -> cache : set('poll:sab-history', ..., cacheTTL)
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
poller -> poller : polling = false\nlog elapsed time
deactivate poller
@enduml
+65
View File
@@ -0,0 +1,65 @@
@startuml state-poller
!theme plain
title sofarr — Poller State Diagram
[*] --> CheckConfig : startPoller()
state CheckConfig <<choice>>
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
CheckConfig --> Idle : POLL_INTERVAL > 0
state Disabled {
state "On-demand mode\nNo background timer" as od
od : Data fetched only when\na dashboard request\nfinds empty cache
}
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
Polling --> Disabled : Poll complete\n(return to on-demand)
state Idle {
state "Waiting for\nnext interval" as waiting
}
Idle --> Polling : setInterval fires\nor immediate first poll
state Polling {
state "polling = true" as lock
state "Fetching all services\n(Promise.all)" as fetching
state "Storing results\nin cache" as storing
state "Recording timings" as timing
[*] --> lock
lock --> fetching
fetching --> storing : All promises resolved
fetching --> ErrorState : Any individual service\nerror (caught per-service)
storing --> timing
timing --> [*] : polling = false
}
state ErrorState as "Handle Error" {
state "Log error\npolling = false" as err
}
ErrorState --> Idle : Next interval
Polling --> Idle : Poll complete\n(back to waiting)
state "Concurrent Poll\nAttempt" as skip {
state "polling === true\n→ skip" as sk
}
Idle --> skip : Interval fires while\nprevious still running
skip --> Idle : Log "still running,\nskipping"
note right of Polling
**Cache TTL**: POLL_INTERVAL × 3
Ensures data survives between polls
even if one cycle is slow.
end note
note right of Disabled
**Cache TTL**: 30000ms (30s)
After expiry, next dashboard
request triggers a fresh poll.
end note
@enduml
+79
View File
@@ -0,0 +1,79 @@
@startuml state-ui
!theme plain
title sofarr — Frontend UI State Diagram
[*] --> SplashScreen : Page load
state SplashScreen {
state "Showing splash\n(min 1.2s)" as showing
}
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session cookie
CheckAuth --> Dashboard : Valid session
state LoginForm {
state "Idle" as lf_idle
state "Submitting" as lf_submit
state "Error" as lf_error
lf_idle --> lf_submit : Submit form
lf_submit --> lf_error : Auth failed
lf_error --> lf_submit : Re-submit
lf_submit --> FadeOutLogin : Auth success
}
state FadeOutLogin {
state "CSS transition\n(opacity → 0)" as fade
}
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
state SplashScreen2 as "Splash (loading data)" {
state "fetchUserDownloads()" as fetching
}
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
state Dashboard {
state "Rendering Cards" as rendering
state "Auto Refreshing" as refreshing
state "Status Panel Open" as status_open
state "Status Panel Closed" as status_closed
[*] --> rendering
rendering --> refreshing : startAutoRefresh()
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
rendering --> rendering : Theme change
status_closed --> status_open : Click "Status" btn\n(admin only)
status_open --> status_closed : Click close (×)
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
[*] --> status_closed
state "Refresh Rate" as rr {
state "1s" as r1
state "5s (default)" as r5
state "10s" as r10
state "Off" as roff
r5 --> r1 : User selects
r5 --> r10
r5 --> roff
r1 --> r5
r1 --> r10
r1 --> roff
r10 --> r1
r10 --> r5
r10 --> roff
roff --> r1
roff --> r5
roff --> r10
}
}
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
@enduml
+1281 -6461
View File
File diff suppressed because it is too large Load Diff
+9 -30
View File
@@ -1,45 +1,24 @@
{
"name": "sofarr",
"version": "1.7.10",
"version": "0.1.3",
"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",
"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",
"generate:openapi": "node scripts/generate-openapi.js",
"generate:raml": "node scripts/downgrade-openapi.js && node scripts/simple-raml-converter.js",
"package:raml": "node scripts/package-raml.js"
"install:all": "npm install"
},
"dependencies": {
"axios": "^1.6.0",
"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",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"xmlrpc": "^1.3.2",
"yamljs": "^0.3.0"
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@stoplight/spectral-cli": "^6.16.0",
"@vitest/coverage-v8": "^4.1.6",
"archiver": "^7.0.1",
"concurrently": "^7.6.0",
"nock": "^14.0.15",
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"vitest": "^4.1.6"
"nodemon": "^2.0.22",
"concurrently": "^7.6.0"
},
"keywords": [
"sabnzbd",
-5
View File
@@ -1,5 +0,0 @@
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
+737 -26
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m256.1 2.6 142 217.2c91 139.2-4.7 289.6-142.1 289.6S22.8 358.9 113.9 219.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#4c90e8;stroke:#094491;stroke-width:3.1904"/><path d="M306.7 255.2c-77.6-31.7-118.4 49.2-105.4 87C229.5 424.4 335.8 445 414.2 308c0 0 .8 16.5 1.7 24.4 10.5 99.9-73.5 163.4-158.6 162.2s-111.1-34.3-136.2-72.3C80.4 360.6 90.5 260 145.2 212.9c62.5-51.6 131.2-24 161.5 42.3" style="fill-rule:evenodd;clip-rule:evenodd;fill:#094491"/><path d="M257.9 225.3c-87 2.1-103.5 102.3-79.4 145.3 33.5 59.7 84.3 71.2 153.8 49.1-39.7 43.2-121.2 54.6-176.5-7.1-38.1-42.4-41.4-101.6-15-151.1 26.5-49.4 79.2-63.2 117.1-36.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#83b8f9"/></svg>

Before

Width:  |  Height:  |  Size: 786 B

-1
View File
@@ -1 +0,0 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="348.2829" x2="782.05951" y1="0" y2="786.48322"><stop offset="0" stop-color="#72b4f5"/><stop offset="1" stop-color="#356ebf"/></linearGradient><g fill="none" fill-rule="evenodd" transform="matrix(.97656268 0 0 .9765624 11.999908 12.000051)"><circle cx="512" cy="512" fill="url(#a)" r="496" stroke="#daefff" stroke-width="32"/><path d="m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z" fill="#fff"/><path d="m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z" fill="#c8e8ff"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linejoin="round" stroke-width="74" d="M200.4 39.3h598.1v437.8h161l-460.1 483L39.4 477h161z"/><path fill="#ffb300" fill-rule="evenodd" d="M200.4 39.3h598.1v437.8h161l-460.1 483-460-483h161z"/><path fill="#ffca28" fill-rule="evenodd" d="M499.4 960.2 201.1 39.4h596.7z"/><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linecap="round" stroke-linejoin="round" stroke-width="74" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-97.8h46v46h-46zm192.1 97.8v-344h100.1v97.4h146.1v246.6zm100.1-195.2h46v143.4h-46z"/><path fill="#0f0f0f" fill-rule="evenodd" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-51.8h46v-46h-46zm192.1 51.9v-344h100.1V597h146.1v246.6zm100.1-51.9h46V648.4h-46z"/></svg>

Before

Width:  |  Height:  |  Size: 966 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st0{fill:#24292e}</style><g id="Group-Copy" transform="translate(70 21)"><path id="Shape" d="m10.3 59.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L53.4 36.2C29.9 20.6 10.3 24.5 10.3 59.8" class="st0"/><path id="Shape_00000114049535938561773820000018271523940913105341_" d="M-13.2 451.8c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L96.5 483.2c-39.2 19.6-90.1 0-109.7-31.4" class="st0"/><path id="Shape_00000165935924413286433040000003668002807793862576_" d="M80.9 342 273 232.3 84.8 126.4z" style="fill:#ffc230"/></g></svg>

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M511.8 256c0 70.4-24.9 130.8-74.6 181.1-1.7 2-3.5 3.8-5.5 5.4-8.2 8-16.8 15.3-26 21.8Q341.05 512 256.3 512c-56.6 0-106.3-15.9-149.2-47.7-11.3-8-22-17.1-31.9-27.3C36.5 398.7 12.8 354 4 303.2c-1.7-9.9-2.9-20-3.4-30.2-.2-5.7-.4-11.3-.4-17 0-6 .1-11.7.4-17.1 0-.6.2-1.1.5-1.7 3.7-62.8 28.4-117 74.1-162.8C125.5 24.8 185.8 0 256.2 0c70.7 0 131 24.8 180.9 74.5q74.7 75.9 74.7 181.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#eee"/><path d="m459.7 100.3-52.9 52.9c-30.9 30.9-33.6 57.8-33.6 105.3 0 42.3 6.7 81.1 38.2 112.6 23 23 44.9 44.7 44.9 44.7-5.9 7.2-12.3 14.3-19.1 21.2-1.7 2-3.5 3.8-5.5 5.4-6 5.9-12.2 11.4-18.6 16.4l-41.4-41.4C334.9 380.6 305.6 377 257 377c-46.7 0-78.4 4.3-112.6 38.5-20.4 20.4-43.8 43.9-43.8 43.9-8.9-6.8-17.3-14.2-25.3-22.4-6.6-6.6-12.8-13.4-18.5-20.3 0 0 23.1-23.2 45.2-45.3 32.7-32.7 38-70.6 38-113 0-41.3-6.8-79.8-36.8-109.9C82.2 127.7 53.3 99 53.3 99c6.7-8.5 14-16.7 21.8-24.5 6.9-6.8 14-13.1 21.2-19l48 48c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4C391 82.1 417 56.3 417 56.3c6.8 5.6 13.5 11.6 20.1 18.2 8.3 8.3 15.8 16.9 22.6 25.8" style="fill-rule:evenodd;clip-rule:evenodd;fill:#3a3f51"/><path d="M186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37" style="fill-rule:evenodd;clip-rule:evenodd;fill:#0cf"/><path d="m372.7 141-35.4 34.6M72.9 76.8l96.5 96.1m199.7 198.9 65.6 67.9m4.4-363.3L372.7 141M76.6 438.5l64.6-64.7" style="fill:none;stroke:#0cf;stroke-width:2;stroke-miterlimit:1"/><path d="m372.7 141-40 40.6m-193.3-38.5 40.6 40.5M141 374l39.5-41.1m146.2-3.3 42.6 42.4" style="fill:none;stroke:#0cf;stroke-width:7;stroke-miterlimit:1"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+25 -252
View File
@@ -4,10 +4,6 @@
<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>
@@ -18,7 +14,7 @@
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container hidden">
<div id="login-container" class="login-container" style="display: none;">
<div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
@@ -31,29 +27,32 @@
<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 hidden"></div>
<div id="login-error" class="error-message" style="display: none;"></div>
</div>
</div>
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container hidden">
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<header class="app-header">
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
<h1>sofarr</h1>
<div class="header-controls">
<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 hidden">
<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>
<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>
@@ -68,249 +67,23 @@
</div>
</header>
<div id="status-panel" class="status-panel hidden">
<!-- Status content gets rendered here -->
<div id="status-content"><p class="status-loading">Loading status...</p></div>
</div>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
<div class="webhooks-section hidden" id="webhooks-section">
<div class="webhooks-header" id="webhooks-header">
<h2>⚡ Webhooks Configuration</h2>
<span class="webhooks-toggle" id="webhooks-toggle"></span>
</div>
<div class="webhooks-content hidden" id="webhooks-content">
<div id="webhook-loading" class="webhook-loading hidden">Loading webhook status...</div>
<!-- Sonarr Webhook -->
<div class="webhook-instance">
<h3>Sonarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
<button id="enable-sonarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-sonarr-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="sonarr-triggers">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats hidden" id="sonarr-stats">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
</div>
</div>
</div>
<!-- Radarr Webhook -->
<div class="webhook-instance">
<h3>Radarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="radarr-status">○ Disabled</span>
<button id="enable-radarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-radarr-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="radarr-triggers">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats hidden" id="radarr-stats">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
</div>
</div>
</div>
<!-- Ombi Webhook -->
<div class="webhook-instance">
<h3>Ombi</h3>
<div class="webhook-status">
<span class="status-indicator" id="ombi-status">○ Disabled</span>
<button id="enable-ombi-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-ombi-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="ombi-triggers">
<div class="trigger-item"><span class="trigger-label">Request Available</span><span class="trigger-value" id="ombi-requestAvailable"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Approved</span><span class="trigger-value" id="ombi-requestApproved"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Declined</span><span class="trigger-value" id="ombi-requestDeclined"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Pending</span><span class="trigger-value" id="ombi-requestPending"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Processing</span><span class="trigger-value" id="ombi-requestProcessing"></span></div>
</div>
<div class="webhook-stats hidden" id="ombi-stats">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="ombi-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="ombi-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="ombi-last">Never</span></div>
</div>
</div>
</div>
</div>
</div>
<div id="error-message" class="error-message hidden"></div>
<div id="loading" class="loading hidden">Loading downloads...</div>
<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="requests">Requests</button>
<button class="tab-btn" data-tab="history">Recently Completed</button>
</div>
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div class="downloads-header">
<div class="downloads-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label>
<div class="download-client-filter" id="download-client-filter">
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
<span id="download-client-selected-text">All clients</span>
<span class="dropdown-arrow"></span>
</button>
<div class="download-client-dropdown" id="download-client-dropdown">
<div class="download-client-dropdown-header">
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
</div>
<div class="download-client-options" id="download-client-options">
<!-- Options will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<div id="no-downloads" class="no-downloads hidden">
<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 hidden" id="tab-requests">
<div class="requests-container">
<div class="requests-header">
<div class="requests-controls">
<!-- Media Type Filter -->
<div class="request-filter" id="request-type-filter">
<label class="request-filter-label">Type:</label>
<button class="request-filter-btn" id="request-type-filter-btn" type="button" aria-expanded="false">
<span id="request-type-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
<div class="request-filter-dropdown-header">
<button class="request-filter-dropdown-btn-small" id="request-type-select-all" type="button">Select All</button>
<button class="request-filter-dropdown-btn-small" id="request-type-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-type-options">
<div class="request-filter-option" data-value="movie">
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
<label for="request-type-movie">Movies</label>
</div>
<div class="request-filter-option" data-value="tv">
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
<label for="request-type-tv">TV Shows</label>
</div>
</div>
</div>
</div>
<!-- Status Filter -->
<div class="request-filter" id="request-status-filter">
<label class="request-filter-label">Status:</label>
<button class="request-filter-btn" id="request-status-filter-btn" type="button" aria-expanded="false">
<span id="request-status-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
<div class="request-filter-dropdown-header">
<button class="request-filter-dropdown-btn-small" id="request-status-select-all" type="button">Select All</button>
<button class="request-filter-dropdown-btn-small" id="request-status-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-status-options">
<div class="request-filter-option" data-value="pending">
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
<label for="request-status-pending">Pending</label>
</div>
<div class="request-filter-option" data-value="approved">
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
<label for="request-status-approved">Approved</label>
</div>
<div class="request-filter-option" data-value="available">
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
<label for="request-status-available">Available</label>
</div>
<div class="request-filter-option" data-value="denied">
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
<label for="request-status-denied">Denied</label>
</div>
</div>
</div>
</div>
<!-- Sort Dropdown -->
<div class="request-sort">
<label class="request-filter-label" for="request-sort-select">Sort:</label>
<select id="request-sort-select" class="request-sort-select">
<option value="requestedDate_desc">Newest to oldest</option>
<option value="requestedDate_asc">Oldest to newest</option>
<option value="title_asc">AZ</option>
<option value="title_desc">ZA</option>
</select>
</div>
<!-- Search Input -->
<div class="request-search">
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
</div>
</div>
</div>
<div id="no-requests" class="no-requests hidden">
<p>No requests found.</p>
</div>
<div id="requests-list" class="requests-list"></div>
</div>
</div>
<div class="tab-panel hidden" id="tab-history">
<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">&#8635;</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 hidden">Loading history...</div>
<div id="history-error" class="history-error hidden"></div>
<div id="no-history" class="no-history hidden">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
</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>
<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>
+50 -1442
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
// Swagger UI authentication banner
// This banner explains the cookie + CSRF authentication flow
(function() {
window.addEventListener('load', function() {
const banner = document.createElement('div');
banner.style.cssText = `
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px 16px;
margin: 16px;
font-family: sans-serif;
font-size: 14px;
line-height: 1.5;
color: #856404;
`;
banner.innerHTML = `
<strong>Authentication Required for Most Endpoints</strong><br>
sofarr uses cookie-based authentication with Emby/Jellyfin. To test authenticated endpoints:<br>
1. Call <code>POST /api/auth/login</code> with your username and password<br>
2. The server sets an <code>emby_user</code> cookie and <code>csrf_token</code> cookie<br>
3. Include these cookies in subsequent requests<br>
4. For state-changing operations (POST/PUT/PATCH/DELETE), also send the <code>X-CSRF-Token</code> header<br>
<br>
<em>Note: The Swagger UI "Authorize" button is not used. Authentication is handled via cookies.</em>
`;
// Insert after the topbar (which we hide with CSS) or at the top of the info section
const info = document.querySelector('.info');
if (info) {
info.insertBefore(banner, info.firstChild);
}
});
})();
-61
View File
@@ -1,61 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Converts OpenAPI 3.0 to RAML 1.0 using AMF (amf-client-js)
* AMF is the modern replacement for deprecated RAML converters.
*/
const { Main, AMFParser, AMFTransformer } = require('amf-client-js');
const fs = require('fs');
const path = require('path');
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
const OUTPUT_FILE = path.join(process.cwd(), 'dist/api.raml');
async function convertToRaml() {
if (!fs.existsSync(INPUT_FILE)) {
throw new Error(`Input file not found: ${INPUT_FILE}`);
}
console.log('Initializing AMF...');
await Main.init();
console.log(`Reading OpenAPI 3.0 spec from ${INPUT_FILE}`);
const specContent = fs.readFileSync(INPUT_FILE, 'utf-8');
console.log('Parsing OpenAPI spec...');
const parser = new AMFParser();
const model = await parser.parseStringAsync('file://' + INPUT_FILE, specContent, 'application/json');
console.log('Resolving references...');
const resolvedModel = await AMFTransformer.resolve(model);
console.log('Converting to RAML 1.0...');
const ramlModel = await AMFTransformer.transform(resolvedModel, 'RAML 1.0');
console.log('Generating RAML output...');
const ramlContent = await AMFTransformer.generateString(ramlModel, 'application/yaml');
// Clean up the output - AMF sometimes adds extra formatting
const cleanedRaml = ramlContent
.replace('#%RAML 1.0\n', '#%RAML 1.0\n\n')
.replace(/\n{3,}/g, '\n\n');
fs.writeFileSync(OUTPUT_FILE, cleanedRaml);
console.log(`✓ RAML spec written to ${OUTPUT_FILE}`);
// Basic validation
if (!cleanedRaml.includes('#%RAML 1.0')) {
throw new Error('Generated RAML does not appear to be valid RAML 1.0');
}
console.log('RAML conversion complete');
}
convertToRaml()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Failed to convert to RAML:', error);
process.exit(1);
});
-47
View File
@@ -1,47 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Downgrades OpenAPI 3.1.0 to 3.0.0 for compatibility with RAML converters.
* OpenAPI 3.1 has limited support in existing RAML conversion tools.
*/
const fs = require('fs');
const path = require('path');
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-merged.json');
const OUTPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
function downgradeOpenApi30(spec) {
// Change version from 3.1.0 to 3.0.0
spec.openapi = '3.0.0';
// OpenAPI 3.1 uses "type" with array for nullable, 3.0 uses nullable: true
// This is a simple pass-through for now - complex schemas may need more handling
// For this spec, most nullable fields are already using 3.0-compatible syntax
return spec;
}
async function main() {
if (!fs.existsSync(INPUT_FILE)) {
throw new Error(`Input file not found: ${INPUT_FILE}`);
}
console.log(`Reading OpenAPI 3.1 spec from ${INPUT_FILE}`);
const spec = JSON.parse(fs.readFileSync(INPUT_FILE, 'utf-8'));
console.log('Downgrading to OpenAPI 3.0.0...');
const downgraded = downgradeOpenApi30(spec);
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(downgraded, null, 2));
console.log(`✓ Downgraded spec written to ${OUTPUT_FILE}`);
}
main()
.then(() => {
console.log('Downgrade complete');
process.exit(0);
})
.catch((error) => {
console.error('Failed to downgrade spec:', error);
process.exit(1);
});
-108
View File
@@ -1,108 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Generates the merged OpenAPI spec by bootstrapping the Express app
* and fetching the spec from /api/swagger.json.
*
* This ensures the generated spec matches exactly what users see in production.
*/
const { createApp } = require('../server/app.js');
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 34567; // Use a different port to avoid conflicts
const OUTPUT_DIR = path.join(process.cwd(), 'dist');
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'openapi-merged.json');
async function generateOpenApiSpec() {
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('Bootstrapping Express app in test mode...');
const app = createApp({ skipRateLimits: true });
return new Promise((resolve, reject) => {
const server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
// Fetch the merged spec
const options = {
hostname: 'localhost',
port: PORT,
path: '/api/swagger.json',
method: 'GET'
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const spec = JSON.parse(data);
// Validate it's a proper OpenAPI spec
if (!spec.openapi || !spec.info) {
throw new Error('Invalid OpenAPI spec: missing openapi or info field');
}
// Write to file
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(spec, null, 2));
console.log(`✓ OpenAPI spec written to ${OUTPUT_FILE}`);
console.log(` Version: ${spec.openapi}`);
console.log(` Title: ${spec.info.title}`);
server.close(() => {
resolve();
});
} catch (error) {
console.error('Error processing OpenAPI spec:', error.message);
server.close(() => {
reject(error);
});
}
});
});
req.on('error', (error) => {
console.error('Error fetching spec:', error.message);
server.close(() => {
reject(error);
});
});
req.end();
});
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${PORT} is already in use`));
} else {
reject(error);
}
});
});
}
// Run if executed directly
if (require.main === module) {
generateOpenApiSpec()
.then(() => {
console.log('OpenAPI spec generation complete');
process.exit(0);
})
.catch((error) => {
console.error('Failed to generate OpenAPI spec:', error);
process.exit(1);
});
}
module.exports = { generateOpenApiSpec };
-184
View File
@@ -1,184 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Creates a versioned tar.gz archive containing the RAML spec,
* original OpenAPI spec, version metadata, and README.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const archiver = require('archiver');
const DIST_DIR = path.join(process.cwd(), 'dist');
const RAML_FILE = path.join(DIST_DIR, 'api.raml');
const OPENAPI_FILE = path.join(DIST_DIR, 'openapi-merged.json');
function getVersion() {
try {
// Try to get the exact tag if we're on one
const tag = execSync('git describe --tags --exact-match 2>/dev/null', { encoding: 'utf-8' }).trim();
if (tag) return tag;
} catch (e) {
// Not on a tag, fall back to SHA
}
try {
// Get short commit SHA
const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
return sha;
} catch (e) {
// Not in a git repo, use timestamp
return `dev-${Date.now()}`;
}
}
function getCommitSha() {
try {
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
} catch (e) {
return 'unknown';
}
}
function createVersionJson(version, commitSha) {
return {
version,
commit: commitSha,
generatedAt: new Date().toISOString(),
tool: 'oas3-to-raml',
openapiVersion: '3.1.0',
ramlVersion: '1.0'
};
}
function createReadme(version, commitSha) {
return `# sofarr RAML 1.0 Specification
## Origin
This RAML specification was automatically generated from the sofarr OpenAPI 3.1.0 specification.
- **Version:** ${version}
- **Commit:** ${commitSha}
- **Generated At:** ${new Date().toISOString()}
- **Conversion Tool:** oas3-to-raml (npx)
## Contents
- \`api.raml\` - The RAML 1.0 specification
- \`openapi-merged.json\` - Original merged OpenAPI 3.1.0 spec (for reference)
- \`version.json\` - Metadata about this generation
## Known Limitations
This RAML spec was converted from OpenAPI 3.1.0. Some OpenAPI 3.1 features may not translate perfectly to RAML 1.0:
- Cookie-based authentication (CookieAuth) may require manual mapping to RAML security schemes
- Advanced schema features (e.g., certain keywords, complex polymorphism) may be approximated or dropped
- Webhook-specific features may not be fully represented
For the most accurate API documentation, refer to the live Swagger UI at \`/api/swagger\` or the original OpenAPI spec included in this archive.
## Verification Steps
1. Validate the RAML spec:
\`\`\`bash
npx raml-1-parser validate api.raml
\`\`\`
2. Compare endpoints with the live Swagger UI at \`/api/swagger\`
3. Test in a RAML-aware tool (e.g., API Workbench, MuleSoft Anypoint)
## Quick Start
To use this RAML spec:
1. Extract the archive
2. Open \`api.raml\` in your preferred RAML tool
3. For development, import it into API Workbench or similar tools
## Source
This artifact was generated from the sofarr project:
https://git.i3omb.com/Gandalf/sofarr
Generated from CI run on commit ${commitSha}.
`;
}
async function packageRaml() {
const version = getVersion();
const commitSha = getCommitSha();
const archiveName = `raml-${version}`;
const archivePath = path.join(DIST_DIR, `${archiveName}.tar.gz`);
const stagingDir = path.join(DIST_DIR, archiveName);
console.log(`Packaging RAML for version: ${version}`);
console.log(`Commit: ${commitSha}`);
// Check that required files exist
if (!fs.existsSync(RAML_FILE)) {
throw new Error(`RAML file not found: ${RAML_FILE}`);
}
if (!fs.existsSync(OPENAPI_FILE)) {
throw new Error(`OpenAPI file not found: ${OPENAPI_FILE}`);
}
// Create staging directory
if (fs.existsSync(stagingDir)) {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
fs.mkdirSync(stagingDir, { recursive: true });
// Copy files to staging directory
fs.copyFileSync(RAML_FILE, path.join(stagingDir, 'api.raml'));
fs.copyFileSync(OPENAPI_FILE, path.join(stagingDir, 'openapi-merged.json'));
// Create version.json
const versionJson = createVersionJson(version, commitSha);
fs.writeFileSync(path.join(stagingDir, 'version.json'), JSON.stringify(versionJson, null, 2));
// Create README.md
const readme = createReadme(version, commitSha);
fs.writeFileSync(path.join(stagingDir, 'README.md'), readme);
// Create tar.gz archive
console.log(`Creating archive: ${archivePath}`);
const output = fs.createWriteStream(archivePath);
const archive = archiver('tar', { gzip: true });
return new Promise((resolve, reject) => {
output.on('close', () => {
console.log(`✓ Archive created: ${archivePath}`);
console.log(` Size: ${archive.pointer()} bytes`);
resolve();
});
archive.on('error', (err) => {
reject(err);
});
archive.pipe(output);
archive.directory(stagingDir, false);
archive.finalize();
}).then(() => {
// Clean up staging directory
fs.rmSync(stagingDir, { recursive: true, force: true });
});
}
// Run if executed directly
if (require.main === module) {
packageRaml()
.then(() => {
console.log('RAML packaging complete');
process.exit(0);
})
.catch((error) => {
console.error('Failed to package RAML:', error);
process.exit(1);
});
}
module.exports = { packageRaml };
-183
View File
@@ -1,183 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Simple OpenAPI 3.0 to RAML 1.0 converter.
* This is a basic converter that handles the essential parts of the sofarr API.
* For a production system, you'd want a more sophisticated converter.
*/
const fs = require('fs');
const path = require('path');
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
const OUTPUT_FILE = path.join(process.cwd(), 'dist/api.raml');
function convertToRaml(spec) {
const lines = [];
// RAML header
lines.push('#%RAML 1.0');
lines.push('');
// Title and version
lines.push(`title: ${spec.info.title}`);
if (spec.info.version) {
lines.push(`version: ${spec.info.version}`);
}
if (spec.info.description) {
lines.push(`description: |`);
spec.info.description.split('\n').forEach(line => {
lines.push(` ${line}`);
});
}
lines.push('');
// Base URI
if (spec.servers && spec.servers.length > 0) {
lines.push(`baseUri: ${spec.servers[0].url}`);
lines.push('');
}
// Security Schemes
if (spec.components && spec.components.securitySchemes) {
lines.push('securitySchemes:');
for (const [name, scheme] of Object.entries(spec.components.securitySchemes)) {
lines.push(` ${name}:`);
if (scheme.type === 'apiKey') {
lines.push(` type: Api Key`);
lines.push(` describedBy:`);
lines.push(` headers:`);
lines.push(` Authorization:`);
lines.push(` description: API key for authentication`);
lines.push(` type: string`);
} else if (scheme.type === 'http' && scheme.scheme === 'bearer') {
lines.push(` type: OAuth 2.0`);
lines.push(` settings:`);
lines.push(` authorizationUri: ${scheme.bearerFormat || 'Bearer'}`);
}
}
lines.push('');
}
// Types (schemas)
if (spec.components && spec.components.schemas) {
lines.push('types:');
for (const [name, schema] of Object.entries(spec.components.schemas)) {
lines.push(` ${name}:`);
if (schema.type === 'object') {
lines.push(` type: object`);
if (schema.properties) {
lines.push(` properties:`);
for (const [propName, prop] of Object.entries(schema.properties)) {
lines.push(` ${propName}:`);
lines.push(` type: ${mapJsonTypeToRaml(prop.type || 'string')}`);
if (prop.description) {
lines.push(` description: ${prop.description}`);
}
}
}
} else {
lines.push(` type: ${mapJsonTypeToRaml(schema.type || 'string')}`);
}
}
lines.push('');
}
// Paths
if (spec.paths) {
for (const [path, pathItem] of Object.entries(spec.paths)) {
lines.push(`/${path.replace(/^\//, '')}:`);
// Methods
for (const [method, operation] of Object.entries(pathItem)) {
if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) {
lines.push(` ${method}:`);
if (operation.summary) {
lines.push(` displayName: ${operation.summary}`);
}
if (operation.description) {
lines.push(` description: |`);
operation.description.split('\n').forEach(line => {
lines.push(` ${line}`);
});
}
// Query parameters
if (operation.parameters) {
const queryParams = operation.parameters.filter(p => p.in === 'query');
if (queryParams.length > 0) {
lines.push(` queryParameters:`);
queryParams.forEach(param => {
lines.push(` ${param.name}:`);
lines.push(` type: ${mapJsonTypeToRaml(param.schema?.type || 'string')}`);
lines.push(` required: ${param.required || false}`);
if (param.description) {
lines.push(` description: ${param.description}`);
}
});
}
}
// Responses
if (operation.responses) {
lines.push(` responses:`);
for (const [code, response] of Object.entries(operation.responses)) {
lines.push(` ${code}:`);
if (response.description) {
lines.push(` description: ${response.description}`);
}
if (response.content && response.content['application/json']) {
const schema = response.content['application/json'].schema;
if (schema && schema.$ref) {
const refName = schema.$ref.replace('#/components/schemas/', '');
lines.push(` body:`);
lines.push(` application/json:`);
lines.push(` type: ${refName}`);
}
}
}
}
}
}
lines.push('');
}
}
return lines.join('\n');
}
function mapJsonTypeToRaml(jsonType) {
const typeMap = {
'string': 'string',
'integer': 'integer',
'number': 'number',
'boolean': 'boolean',
'array': 'array',
'object': 'object'
};
return typeMap[jsonType] || 'string';
}
async function main() {
if (!fs.existsSync(INPUT_FILE)) {
throw new Error(`Input file not found: ${INPUT_FILE}`);
}
console.log(`Reading OpenAPI 3.0 spec from ${INPUT_FILE}`);
const spec = JSON.parse(fs.readFileSync(INPUT_FILE, 'utf-8'));
console.log('Converting to RAML 1.0...');
const ramlContent = convertToRaml(spec);
fs.writeFileSync(OUTPUT_FILE, ramlContent);
console.log(`✓ RAML spec written to ${OUTPUT_FILE}`);
console.log('RAML conversion complete');
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Failed to convert to RAML:', error);
process.exit(1);
});
-238
View File
@@ -1,238 +0,0 @@
// 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 swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const YAML = require('yamljs');
const path = require('path');
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 statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
const app = express();
// Load OpenAPI spec from YAML
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
// Configure swagger-jsdoc to merge JSDoc comments from route files
const swaggerOptions = {
definition: {
...openapiSpec,
openapi: '3.1.0'
},
apis: [
path.join(__dirname, 'routes/*.js'),
path.join(__dirname, 'app.js'),
path.join(__dirname, 'index.js')
]
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
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,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
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)
/**
* @openapi
* /health:
* get:
* tags: [Health]
* summary: Health check
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
* security: []
* responses:
* '200':
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ok"
* uptime:
* type: number
* description: Server uptime in seconds
* example: 3600.5
* x-code-samples:
* - lang: curl
* label: cURL
* source: curl http://localhost:3001/health
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
/**
* @openapi
* /ready:
* get:
* tags: [Health]
* summary: Readiness check
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
* security: []
* responses:
* '200':
* description: Server is ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ready"
* '503':
* description: Server not ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "not ready"
* reason:
* type: string
* example: "EMBY_URL not configured"
* x-code-samples:
* - lang: curl
* label: cURL
* source: curl http://localhost:3001/ready
*/
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' });
}
});
// Swagger UI - publicly accessible API documentation
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
customSiteTitle: 'sofarr API Documentation',
customCss: '.swagger-ui .topbar { display: none }',
customJs: [
'/swagger-auth-banner.js'
],
swaggerOptions: {
url: '/api/swagger.json'
}
}));
// Serve the raw OpenAPI spec as JSON with dynamic server URL
app.get('/api/swagger.json', (req, res) => {
// Clone the spec to avoid modifying the original
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
// Replace the server URL with the current request's origin
if (specCopy.servers && specCopy.servers.length > 0) {
const protocol = req.protocol;
const host = req.get('host');
specCopy.servers[0].url = `${protocol}://${host}`;
}
res.json(specCopy);
});
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// 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/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
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 };
-78
View File
@@ -1,78 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Abstract base class for all *arr data retrievers.
* Defines the common interface that all retrievers must implement.
* This pluggable layer enables future retrieval strategies (e.g., webhook listeners)
* to push normalized data directly into the existing cache and SSE system
* without touching the poller logic.
*/
class ArrRetriever {
/**
* @param {Object} instanceConfig - Configuration for this retriever 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 *arr API
* @param {string} instanceConfig.apiKey - API key for authentication
*/
constructor(instanceConfig) {
if (this.constructor === ArrRetriever) {
throw new Error('ArrRetriever 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;
}
/**
* Get the retriever type identifier (e.g., 'sonarr', 'radarr')
* @returns {string} The retriever type
*/
getRetrieverType() {
throw new Error('getRetrieverType() must be implemented by subclass');
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Get tags from this *arr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
throw new Error('getTags() must be implemented by subclass');
}
/**
* Get queue from this *arr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
throw new Error('getQueue() must be implemented by subclass');
}
/**
* Get history from this *arr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries] - Include series data (Sonarr)
* @param {boolean} [options.includeEpisode] - Include episode data (Sonarr)
* @param {boolean} [options.includeMovie] - Include movie data (Radarr)
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
throw new Error('getHistory() must be implemented by subclass');
}
}
module.exports = ArrRetriever;
-103
View File
@@ -1,103 +0,0 @@
// 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;
-130
View File
@@ -1,130 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const { logToFile } = require('../utils/logger');
/**
* Ombi API client for fetching requests and searching media.
* Provides integration with Ombi request management system.
*/
class OmbiClient {
constructor(url, apiKey) {
this.url = url.replace(/\/$/, ''); // Remove trailing slash
this.apiKey = apiKey;
this.axios = axios.create({
headers: { 'ApiKey': this.apiKey },
timeout: 10000
});
}
/**
* Get all movie requests from Ombi
* @returns {Promise<Array>} Array of movie request objects
*/
async getMovieRequests() {
try {
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
return response.data || [];
} catch (error) {
logToFile(`[OmbiClient] Movie requests error: ${error.message}`);
return [];
}
}
/**
* Get all TV requests from Ombi
* @returns {Promise<Array>} Array of TV request objects
*/
async getTvRequests() {
try {
const response = await this.axios.get(`${this.url}/api/v1/Request/tv`);
return response.data || [];
} catch (error) {
logToFile(`[OmbiClient] TV requests error: ${error.message}`);
return [];
}
}
/**
* Search for movies by TMDB ID
* @param {string} tmdbId - TheMovieDB ID
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchMovieByTmdbId(tmdbId) {
if (!tmdbId) return null;
try {
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/${tmdbId}`);
return response.data || null;
} catch (error) {
logToFile(`[OmbiClient] Movie search error for TMDB ID ${tmdbId}: ${error.message}`);
return null;
}
}
/**
* Search for movies by IMDB ID
* @param {string} imdbId - IMDB ID
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchMovieByImdbId(imdbId) {
if (!imdbId) return null;
try {
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/imdb/${imdbId}`);
return response.data || null;
} catch (error) {
logToFile(`[OmbiClient] Movie search error for IMDB ID ${imdbId}: ${error.message}`);
return null;
}
}
/**
* Search for TV shows by TVDB ID
* @param {string} tvdbId - TheTVDB ID
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchTvByTvdbId(tvdbId) {
if (!tvdbId) return null;
try {
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/${tvdbId}`);
return response.data || null;
} catch (error) {
logToFile(`[OmbiClient] TV search error for TVDB ID ${tvdbId}: ${error.message}`);
return null;
}
}
/**
* Search for TV shows by TMDB ID
* @param {string} tmdbId - TheMovieDB ID
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchTvByTmdbId(tmdbId) {
if (!tmdbId) return null;
try {
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/tmdb/${tmdbId}`);
return response.data || null;
} catch (error) {
logToFile(`[OmbiClient] TV search error for TMDB ID ${tmdbId}: ${error.message}`);
return null;
}
}
/**
* Test connection to Ombi API
* @returns {Promise<boolean>} True if connection is successful
*/
async testConnection() {
try {
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
return response.status === 200;
} catch (error) {
logToFile(`[OmbiClient] Connection test failed: ${error.message}`);
return false;
}
}
}
module.exports = OmbiClient;
-263
View File
@@ -1,263 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const ArrRetriever = require('./ArrRetriever');
const OmbiClient = require('./OmbiClient');
const { logToFile } = require('../utils/logger');
/**
* Ombi data retriever with caching support.
* Extends ArrRetriever for PALDRA compliance.
* Manages Ombi request data and provides lookup maps for efficient matching.
*/
class OmbiRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
this.client = new OmbiClient(this.url, this.apiKey);
this.baseUrl = this.url;
this.cache = {
movieRequests: [],
tvRequests: [],
movieMap: new Map(), // tmdbId -> request
tvMap: new Map(), // tvdbId -> request
lastFetch: 0,
ttl: 5 * 60 * 1000 // 5 minutes TTL
};
}
/**
* Get retriever type
* @returns {string} The retriever type
*/
getRetrieverType() {
return 'ombi';
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Get tags from Ombi (not applicable, returns empty array)
* @returns {Promise<Array>} Empty array (Ombi doesn't have tags)
*/
async getTags() {
return [];
}
/**
* Get queue from Ombi (active requests)
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
await this.refreshCache();
return {
records: [...this.cache.movieRequests, ...this.cache.tvRequests]
};
}
/**
* Get history from Ombi (not applicable, returns empty records)
* @param {Object} options - Optional parameters (ignored for Ombi)
* @returns {Promise<Object>} History object with empty records array
*/
async getHistory(options = {}) {
return {
records: []
};
}
/**
* Test connection to Ombi
* @returns {Promise<boolean>} True if connection is successful
*/
async testConnection() {
return await this.client.testConnection();
}
/**
* Check if cache is expired
* @returns {boolean} True if cache needs refresh
*/
isCacheExpired() {
return Date.now() - this.cache.lastFetch > this.cache.ttl;
}
/**
* Refresh cached data from Ombi API
* @param {boolean} force - Whether to force a refresh regardless of TTL
* @returns {Promise<void>}
*/
async refreshCache(force = false) {
if (!force && !this.isCacheExpired()) {
return;
}
try {
logToFile('[OmbiRetriever] Refreshing cache');
// Fetch requests in parallel
const [movieRequests, tvRequests] = await Promise.all([
this.client.getMovieRequests(),
this.client.getTvRequests()
]);
// Update cache
this.cache.movieRequests = movieRequests;
this.cache.tvRequests = tvRequests;
this.cache.lastFetch = Date.now();
// Build lookup maps
this.cache.movieMap.clear();
this.cache.tvMap.clear();
// Build movie map (tmdbId -> request)
movieRequests.forEach(request => {
if (request.theMovieDbId) {
this.cache.movieMap.set(request.theMovieDbId, request);
}
if (request.imdbId) {
this.cache.movieMap.set(request.imdbId, request);
}
});
// Build TV map (tvdbId -> request, fallback to tmdbId)
tvRequests.forEach(request => {
if (request.theTvDbId) {
this.cache.tvMap.set(request.theTvDbId, request);
}
if (request.theMovieDbId) {
this.cache.tvMap.set(request.theMovieDbId, request);
}
});
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
} catch (error) {
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
// Don't throw error, continue with stale cache if available
}
}
/**
* Get all movie requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of movie request objects
*/
async getMovieRequests(force = false) {
await this.refreshCache(force);
return this.cache.movieRequests;
}
/**
* Get all TV requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of TV request objects
*/
async getTvRequests(force = false) {
await this.refreshCache(force);
return this.cache.tvRequests;
}
/**
* Find movie request by external ID
* @param {string} tmdbId - TheMovieDB ID
* @param {string} imdbId - IMDB ID (optional fallback)
* @returns {Promise<Object|null>} Request object or null if not found
*/
async findMovieRequest(tmdbId, imdbId = null) {
await this.refreshCache();
// Try TMDB ID first
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
return this.cache.movieMap.get(tmdbId);
}
// Try IMDB ID as fallback
if (imdbId && this.cache.movieMap.has(imdbId)) {
return this.cache.movieMap.get(imdbId);
}
return null;
}
/**
* Find TV request by external ID
* @param {string} tvdbId - TheTVDB ID
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
* @returns {Promise<Object|null>} Request object or null if not found
*/
async findTvRequest(tvdbId, tmdbId = null) {
await this.refreshCache();
// Try TVDB ID first
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
return this.cache.tvMap.get(tvdbId);
}
// Try TMDB ID as fallback
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
return this.cache.tvMap.get(tmdbId);
}
return null;
}
/**
* Search for movie by external ID (for fallback when no request found)
* @param {string} tmdbId - TheMovieDB ID
* @param {string} imdbId - IMDB ID (optional fallback)
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchMovie(tmdbId, imdbId = null) {
if (tmdbId) {
const result = await this.client.searchMovieByTmdbId(tmdbId);
if (result) return result;
}
if (imdbId) {
const result = await this.client.searchMovieByImdbId(imdbId);
if (result) return result;
}
return null;
}
/**
* Search for TV show by external ID (for fallback when no request found)
* @param {string} tvdbId - TheTVDB ID
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
* @returns {Promise<Object|null>} Search result object or null if not found
*/
async searchTv(tvdbId, tmdbId = null) {
if (tvdbId) {
const result = await this.client.searchTvByTvdbId(tvdbId);
if (result) return result;
}
if (tmdbId) {
const result = await this.client.searchTvByTmdbId(tmdbId);
if (result) return result;
}
return null;
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
getCacheStats() {
return {
movieRequests: this.cache.movieRequests.length,
tvRequests: this.cache.tvRequests.length,
movieMapSize: this.cache.movieMap.size,
tvMapSize: this.cache.tvMap.size,
lastFetch: this.cache.lastFetch,
age: Date.now() - this.cache.lastFetch
};
}
}
module.exports = OmbiRetriever;
-134
View File
@@ -1,134 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Radarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingRadarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'radarr';
}
/**
* Get tags from Radarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Radarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeMovie: true, page, pageSize: 1000 }
});
responseData = response.data;
} catch (error) {
console.error(`Radarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
page <= 50
);
return {
...responseData,
records: allRecords
};
}
/**
* Get history from Radarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=100] - Number of records to fetch per page
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeMovie=true] - Include movie data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
maxPages = 1,
sortKey,
sortDir,
includeMovie = true,
startDate
} = options;
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
const params = {
page,
pageSize,
includeMovie
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
try {
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
responseData = response.data;
} catch (error) {
console.error(`Radarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
page <= maxPages
);
return {
...responseData,
records: allRecords
};
}
}
module.exports = PollingRadarrRetriever;
-137
View File
@@ -1,137 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Sonarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingSonarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'sonarr';
}
/**
* Get tags from Sonarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Sonarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeSeries: true, includeEpisode: true, page, pageSize: 1000 }
});
responseData = response.data;
} catch (error) {
console.error(`Sonarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
page <= 50
);
return {
...responseData,
records: allRecords
};
}
/**
* Get history from Sonarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=100] - Number of records to fetch per page
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries=true] - Include series data
* @param {boolean} [options.includeEpisode=true] - Include episode data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
maxPages = 1,
sortKey,
sortDir,
includeSeries = true,
includeEpisode = true,
startDate
} = options;
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
const params = {
page,
pageSize,
includeSeries,
includeEpisode
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
try {
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
responseData = response.data;
} catch (error) {
console.error(`Sonarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
page <= maxPages
);
return {
...responseData,
records: allRecords
};
}
}
module.exports = PollingSonarrRetriever;
-266
View File
@@ -1,266 +0,0 @@
// 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();
this.torrentMap = new Map();
for (const torrent of torrents) {
this.torrentMap.set(torrent.hash, torrent);
}
this.lastRid = 0;
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();
this.torrentMap = new Map();
for (const torrent of torrents) {
this.torrentMap.set(torrent.hash, torrent);
}
this.lastRid = 0;
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;
-185
View File
@@ -1,185 +0,0 @@
// 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;
-275
View File
@@ -1,275 +0,0 @@
// 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;
// Extract client status directly from the fetched queue data instead of making a redundant HTTP request
let clientStatus = null;
if (queueData && queueData.queue) {
const q = queueData.queue;
clientStatus = {
status: q.status,
speed: q.speed,
kbpersec: q.kbpersec,
sizeleft: q.sizeleft,
mbleft: q.mbleft,
mb: q.mb,
diskspace1: q.diskspace1,
diskspace2: q.diskspace2,
loadavg: q.loadavg,
pause_int: q.pause_int
};
}
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
const kbpersec = (queueData.queue && queueData.queue.kbpersec) || (clientStatus && clientStatus.kbpersec) || 0;
const globalSpeed = parseFloat(kbpersec) * 1024;
logToFile(`[SABnzbd:${this.name}] Global Speed: ${globalSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
for (const slot of queueData.queue.slots) {
let slotSpeed = 0;
if (slot.status === 'Downloading') {
slotSpeed = globalSpeed;
} else if (slot.status === 'Paused' && slot.kbpersec && parseFloat(slot.kbpersec) > 0) {
slotSpeed = globalSpeed;
}
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
}
}
// 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', 0));
}
}
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, speed) {
const isHistory = source === 'history';
const finalSpeed = speed !== undefined ? speed : (slot.kbpersec ? parseFloat(slot.kbpersec) * 1024 : 0);
// 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;
const hasMb = slot.mb !== undefined && slot.mb !== null;
const hasMbLeft = slot.mbleft !== undefined && slot.mbleft !== null;
const mbValue = hasMb ? parseFloat(slot.mb) : 0;
const mbLeftValue = hasMbLeft ? parseFloat(slot.mbleft) : 0;
if (hasMb && hasMbLeft && mbValue !== 0) {
size = mbValue * 1024 * 1024; // Convert MB to bytes
downloaded = (mbValue - mbLeftValue) * 1024 * 1024;
progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 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: finalSpeed,
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => 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;
-181
View File
@@ -1,181 +0,0 @@
// 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;
+12 -395
View File
@@ -1,48 +1,16 @@
// 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');
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const YAML = require('yamljs');
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;
// 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 logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
@@ -85,385 +53,34 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
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;
// Load OpenAPI spec from YAML
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// Configure swagger-jsdoc to merge JSDoc comments from route files
const swaggerOptions = {
definition: {
...openapiSpec,
openapi: '3.1.0'
},
apis: [
path.join(__dirname, 'routes/*.js'),
path.join(__dirname, 'index.js')
]
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
// 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,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
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.
// ---------------------------------------------------------------------------
/**
* @openapi
* /health:
* get:
* tags: [Health]
* summary: Health check
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
* security: []
* responses:
* '200':
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ok"
* uptime:
* type: number
* description: Server uptime in seconds
* example: 3600.5
* version:
* type: string
* description: sofarr version
* example: "1.6.0"
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
});
/**
* @openapi
* /ready:
* get:
* tags: [Health]
* summary: Readiness check
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
* security: []
* responses:
* '200':
* description: Server is ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ready"
* '503':
* description: Server not ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "not ready"
* reason:
* type: string
* example: "EMBY_URL not configured"
*/
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' });
}
});
// ---------------------------------------------------------------------------
// Swagger UI - publicly accessible API documentation
// ---------------------------------------------------------------------------
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
customSiteTitle: 'sofarr API Documentation',
customCss: '.swagger-ui .topbar { display: none }',
customJs: [
'/swagger-auth-banner.js'
],
swaggerOptions: {
url: '/api/swagger.json'
}
}));
// Serve the raw OpenAPI spec as JSON with dynamic server URL
app.get('/api/swagger.json', (req, res) => {
// Clone the spec to avoid modifying the original
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
// Replace the server URL with the current request's origin
if (specCopy.servers && specCopy.servers.length > 0) {
const protocol = req.protocol;
const host = req.get('host');
specCopy.servers[0].url = `${protocol}://${host}`;
}
res.json(specCopy);
});
// ---------------------------------------------------------------------------
// 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);
app.use('/api/webhook', webhookRoutes);
// 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/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/auth', authRoutes);
// 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.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// ---------------------------------------------------------------------------
// 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, () => {
app.listen(PORT, () => {
console.log(`=================================`);
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(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`);
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'));
-22
View File
@@ -1,22 +0,0 @@
// 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;
-44
View File
@@ -1,44 +0,0 @@
// 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' });
}
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
// Constant-time comparison of underlying buffer lengths to prevent timing attacks
if (a.length !== b.length) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
if (!require('crypto').timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
next();
}
module.exports = verifyCsrf;
-1745
View File
File diff suppressed because it is too large Load Diff
+52 -493
View File
@@ -1,260 +1,60 @@
// 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();
// Persistent JSON file-backed token store — survives restarts
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// 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' }
});
/**
* @openapi
* /api/auth/login:
* post:
* tags: [Auth]
* summary: Authenticate with Emby/Jellyfin
* description: |
* Authenticates a user against Emby/Jellyfin and sets session cookies.
*
* **Rate Limiting:** 10 failed attempts per 15 minutes per IP (successful attempts don't count).
*
* **Authentication Flow:**
* 1. Send username and password in request body
* 2. Server validates credentials with Emby/Jellyfin
* 3. Server sets httpOnly signed cookie `emby_user` containing {id, name, isAdmin}
* 4. Server sets `csrf_token` cookie (readable by JS for double-submit pattern)
* 5. Response includes user data and CSRF token
*
* **Cookie Details:**
* - `emby_user`: httpOnly, signed, sameSite=strict. Persistent if rememberMe=true (30 days), otherwise session cookie.
* - `csrf_token`: httpOnly=false (JS-readable), sameSite=strict. Used for state-changing requests.
*
* **Security Notes:**
* - Password must be 1-256 characters
* - Username must be 1-128 characters
* - Server rejects with 400 if input validation fails
* - Server rejects with 401 if Emby authentication fails
*
* **x-integration-notes:** After successful login, subsequent requests must:
* - Send the emby_user cookie (automatically by browser)
* - Send the X-CSRF-Token header (from csrf_token cookie) for POST/PUT/PATCH/DELETE requests
* - Use credentials: 'include' in fetch/axios to send cookies
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* minLength: 1
* maxLength: 128
* description: Emby/Jellyfin username
* example: "john"
* password:
* type: string
* minLength: 1
* maxLength: 256
* description: Emby/Jellyfin password
* example: "password123"
* rememberMe:
* type: boolean
* description: If true, cookie persists for 30 days; otherwise session cookie
* example: false
* example:
* username: "john"
* password: "password123"
* rememberMe: false
* responses:
* '200':
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* user:
* type: object
* properties:
* id:
* type: string
* description: Emby user ID
* example: "abc123def456"
* name:
* type: string
* description: Display name
* example: "John Doe"
* isAdmin:
* type: boolean
* description: Admin flag
* example: false
* csrfToken:
* type: string
* description: CSRF token for state-changing requests
* example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
* example:
* success: true
* user:
* id: "abc123def456"
* name: "John Doe"
* isAdmin: false
* csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
* '400':
* description: Invalid input (username or password fails validation)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* success: false
* error: "Invalid username"
* '401':
* description: Invalid credentials (Emby authentication failed)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* success: false
* error: "Invalid username or password"
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X POST http://localhost:3001/api/auth/login \
* -H "Content-Type: application/json" \
* -c cookies.txt \
* -d '{"username":"john","password":"password123"}'
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/auth/login', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* credentials: 'include',
* body: JSON.stringify({ username: 'john', password: 'password123' })
* });
* const data = await response.json();
* console.log(data.csrfToken); // Save this for subsequent requests
* - lang: TypeScript
* label: TypeScript
* source: |
* interface LoginResponse {
* success: boolean;
* user: { id: string; name: string; isAdmin: boolean };
* csrfToken: string;
* }
* const response = await fetch('http://localhost:3001/api/auth/login', {
* method: 'POST',
* headers: { 'Content-Type': 'application/json' },
* credentials: 'include',
* body: JSON.stringify({ username: 'john', password: 'password123' })
* });
* const data: LoginResponse = await response.json();
*/
router.post('/login', loginLimiter, async (req, res) => {
// Authenticate user with Emby
router.post('/login', async (req, res) => {
try {
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' });
}
const { username, password } = req.body;
console.log(`[Auth] Attempting login for user: ${username.trim()}`);
console.log(`[Auth] Attempting login for user: ${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(),
// Authenticate with Emby
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", 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(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, {
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
}
});
const user = userResponse.data;
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);
console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}`);
// 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 = {
// Set authentication cookie
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
res.cookie('emby_user', JSON.stringify({
id: user.Id,
name: user.Name,
isAdmin: isAdmin,
token: authData.AccessToken
}), {
httpOnly: true,
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: '/'
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
success: true,
user: { id: user.Id, name: user.Name, isAdmin },
csrfToken
user: {
id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
});
} catch (error) {
console.error(`[Auth] Login failed:`, error.message);
@@ -265,274 +65,33 @@ router.post('/login', loginLimiter, 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;
}
}
/**
* @openapi
* /api/auth/me:
* get:
* tags: [Auth]
* summary: Get current authenticated user
* description: |
* Returns the currently authenticated user from the session cookie.
*
* **Authentication:** Requires valid `emby_user` cookie.
*
* **Response:**
* - If authenticated: returns user data (id, name, isAdmin)
* - If not authenticated: returns authenticated: false
*
* **Use Case:** Check if user is logged in and get user details without re-authenticating.
* security:
* - CookieAuth: []
* responses:
* '200':
* description: User data (authenticated or not)
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* properties:
* authenticated:
* type: boolean
* example: true
* user:
* type: object
* properties:
* id:
* type: string
* example: "abc123def456"
* name:
* type: string
* example: "John Doe"
* isAdmin:
* type: boolean
* example: false
* - type: object
* properties:
* authenticated:
* type: boolean
* example: false
* examples:
* authenticated:
* authenticated: true
* user:
* id: "abc123def456"
* name: "John Doe"
* isAdmin: false
* notAuthenticated:
* authenticated: false
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET http://localhost:3001/api/auth/me \
* -b cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/auth/me', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
* if (data.authenticated) {
* console.log('User:', data.user.name);
* }
*/
// Get current authenticated user
router.get('/me', (req, res) => {
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 }
});
});
/**
* @openapi
* /api/auth/csrf:
* get:
* tags: [Auth]
* summary: Refresh CSRF token
* description: |
* Returns a fresh CSRF token and sets it as a cookie.
*
* **Purpose:** Lets the SPA get a new CSRF token without re-authenticating
* (e.g., after a page reload where the JS variable containing the token was lost).
*
* **Authentication:** No authentication required (CSRF tokens are issued to all clients).
*
* **Cookie Details:**
* - Sets `csrf_token` cookie (httpOnly=false, readable by JS)
* - sameSite=strict, secure when TRUST_PROXY is set
*
* **Use Case:** Call this endpoint when your application needs a fresh CSRF token
* for state-changing requests (POST/PUT/PATCH/DELETE).
* security: []
* responses:
* '200':
* description: CSRF token
* content:
* application/json:
* schema:
* type: object
* properties:
* csrfToken:
* type: string
* description: Fresh CSRF token for state-changing requests
* example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
* example:
* csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET http://localhost:3001/api/auth/csrf \
* -c cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/auth/csrf', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
* const csrfToken = data.csrfToken; // Use this in X-CSRF-Token header
*/
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 });
});
/**
* @openapi
* /api/auth/logout:
* post:
* tags: [Auth]
* summary: Logout
* description: |
* Clears session cookies and revokes the Emby/Jellyfin access token.
*
* **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header.
*
* **Actions Performed:**
* 1. Revokes the Emby/Jellyfin access token on the Emby server
* 2. Clears the server-side token store
* 3. Clears the `emby_user` cookie
* 4. Clears the `csrf_token` cookie
*
* **Error Handling:** If Emby token revocation fails, the logout still succeeds
* (cookies are cleared) but a warning is logged.
*
* **x-integration-notes:** After logout, the client must discard the CSRF token
* and not attempt further authenticated requests until re-authenticating.
* security:
* - CookieAuth: []
* - CsrfToken: []
* responses:
* '200':
* description: Logout successful
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* example:
* success: true
* '401':
* description: Not authenticated (no valid session)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '403':
* description: CSRF token missing or invalid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* # Get CSRF token first
* CSRF_TOKEN=$(curl -s -c cookies.txt http://localhost:3001/api/auth/csrf | jq -r .csrfToken)
* # Logout
* curl -X POST http://localhost:3001/api/auth/logout \
* -H "X-CSRF-Token: $CSRF_TOKEN" \
* -b cookies.txt \
* -c cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', {
* method: 'GET',
* credentials: 'include'
* });
* const { csrfToken } = await csrfResponse.json();
*
* const response = await fetch('http://localhost:3001/api/auth/logout', {
* method: 'POST',
* headers: { 'X-CSRF-Token': csrfToken },
* credentials: 'include'
* });
* const data = await response.json();
* console.log(data.success); // true
*/
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);
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,
isAdmin: !!user.isAdmin
}
});
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
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: '/'
});
});
// Logout
router.post('/logout', (req, res) => {
res.clearCookie('emby_user');
res.json({ success: true });
});
+618 -698
View File
File diff suppressed because it is too large Load Diff
+28 -124
View File
@@ -1,147 +1,51 @@
// 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');
/**
* @openapi
* /api/emby/sessions:
* get:
* tags: [Emby]
* summary: Get active Emby sessions
* description: Proxy to Emby's sessions endpoint. Requires authentication.
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Sessions data from Emby
* content:
* application/json:
* schema:
* type: array
*/
router.use(requireAuth);
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// GET /api/emby/sessions - list active Emby sessions
// Get active sessions
router.get('/sessions', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
}
});
/**
* @openapi
* /api/emby/users:
* get:
* tags: [Emby]
* summary: Get all Emby users
* description: Proxy to Emby's users list endpoint. Requires authentication.
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Users list from Emby
* content:
* application/json:
* schema:
* type: array
*/
// GET /api/emby/users - list all users
router.get('/users', async (req, res) => {
try {
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: sanitizeError(error) });
}
});
/**
* @openapi
* /api/emby/users/{id}:
* get:
* tags: [Emby]
* summary: Get user by ID
* description: Get details for a specific Emby user by ID. Requires authentication.
* security:
* - CookieAuth: []
* parameters:
* - name: id
* in: path
* required: true
* schema:
* type: string
* description: Emby user ID
* responses:
* '200':
* description: User data from Emby
* content:
* application/json:
* schema:
* type: object
* '500':
* description: Failed to fetch user from Emby
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
// GET /api/emby/users/:id - get individual user by ID
// Get user by ID
router.get('/users/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
}
});
/**
* @openapi
* /api/emby/session/{sessionId}/user:
* get:
* tags: [Emby]
* summary: Get user from session
* description: Get user details for a specific session ID. Requires authentication.
* security:
* - CookieAuth: []
* parameters:
* - name: sessionId
* in: path
* required: true
* schema:
* type: string
* description: Emby session ID
* responses:
* '200':
* description: User data from Emby
* content:
* application/json:
* schema:
* type: object
* '404':
* description: Session not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
// GET /api/emby/session/:sessionId/user - get user for a specific session
// 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 }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
}
});
// Get current user by session ID
router.get('/session/:sessionId/user', async (req, res) => {
try {
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
const session = response.data.find(s => s.Id === req.params.sessionId);
@@ -149,13 +53,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
return res.status(404).json({ error: 'Session not found' });
}
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
res.json(userResponse.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
}
});
-380
View File
@@ -1,380 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const sanitizeError = require('../utils/sanitizeError');
const TagMatcher = require('../services/TagMatcher');
const DownloadAssembler = require('../services/DownloadAssembler');
/**
* 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;
}
/**
* @openapi
* /api/history/recent:
* get:
* tags: [History]
* summary: Get recent history
* description: |
* Returns Sonarr/Radarr history records (imported + failed) for the authenticated user,
* filtered to the last N days (default 7, max 90).
*
* **Authentication:** Requires valid `emby_user` cookie.
*
* **Filtering:**
* - Non-admin users: Only see history items tagged with their username
* - Admin users: Can see all history by setting query parameter `showAll=true`
* - Date range: Configurable via `?days=` query parameter (default: 7, max: 90)
*
* **Deduplication Rules:**
* For each unique content item (episode or movie), only the most recent record is shown:
* - If the most recent event is "imported" show it; suppress older failures
* - If the most recent event is "failed" and the item has a file on disk show with `availableForUpgrade=true`
* - If the most recent event is "failed" and no file exists show normally
*
* **Event Classification:**
* - Sonarr: DownloadFolderImported, ImportFailed included
* - Radarr: DownloadFolderImported, ImportFailed included
* - Other event types (Rename, Health, etc.) excluded
*
* **Response Structure:**
* - `type`: "series" or "movie"
* - `outcome`: "imported" or "failed"
* - `title`: Source title from *arr record
* - `seriesName`/`movieName`: Friendly media title
* - `coverArt`: Poster URL
* - `completedAt`: ISO 8601 timestamp
* - `quality`: Quality string (e.g., "HDTV-1080p")
* - `instanceName`: *arr instance name
* - `arrLink`: Link to item in *arr UI
* - `allTags`: All tags on the series/movie
* - `matchedUserTag`: Tag matching the requesting user
* - `availableForUpgrade`: True if failed but content is on disk (admin-only)
* - `failureMessage`: Failure details (admin-only)
*
* **x-integration-notes:** Used by the history tab to show recently completed downloads.
* Episodes are gathered from all history records sharing the same source title.
* security:
* - CookieAuth: []
* parameters:
* - name: days
* in: query
* schema:
* type: integer
* minimum: 1
* maximum: 90
* default: 7
* description: Number of days to look back (max 90)
* example: 7
* - name: showAll
* in: query
* schema:
* type: boolean
* default: false
* description: 'Admin-only: show all users'' history'
* responses:
* '200':
* description: History items
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* type: string
* example: "john"
* isAdmin:
* type: boolean
* example: false
* days:
* type: integer
* example: 7
* history:
* type: array
* items:
* $ref: '#/components/schemas/HistoryItem'
* example:
* user: "john"
* isAdmin: false
* days: 7
* history:
* - type: "series"
* outcome: "imported"
* title: "Show.Name.S01E01.1080p.WEB-DL"
* seriesName: "Show Name"
* episodes:
* - season: 1
* episode: 1
* title: "Pilot"
* coverArt: "http://sonarr:8989/media/poster.jpg"
* completedAt: "2026-05-21T10:00:00.000Z"
* quality: "HDTV-1080p"
* instanceName: "Main Sonarr"
* arrLink: "http://sonarr:8989/series/show-slug"
* allTags: ["user-john"]
* matchedUserTag: "user-john"
* '401':
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '500':
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET "http://localhost:3001/api/history/recent?days=7" \
* -b cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/history/recent?days=7', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
* console.log('History items:', data.history.length);
*/
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 ombiInstances = getOmbiInstances();
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
fetchSonarrHistory(since),
fetchRadarrHistory(since),
showAll ? TagMatcher.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 = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.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: DownloadAssembler.gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
coverArt: DownloadAssembler.getCoverArt(series),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: DownloadAssembler.getSonarrLink(series),
ombiLink: DownloadAssembler.getOmbiDetailsLink(series, 'series', ombiBaseUrl),
ombiTooltip: 'View in Ombi',
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.episodeId != null ? record.episodeId : null
};
if (isAdmin) {
item.arrRecordId = record.id;
item.arrType = 'sonarr';
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 = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.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: DownloadAssembler.getCoverArt(movie),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: DownloadAssembler.getRadarrLink(movie),
ombiLink: DownloadAssembler.getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
ombiTooltip: 'View in Ombi',
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.movieId != null ? record.movieId : null
};
if (isAdmin) {
item.arrRecordId = record.id;
item.arrType = 'radarr';
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;
-508
View File
@@ -1,508 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { applyRequestFilters } = require('../utils/ombiFilters');
const router = express.Router();
/**
* @openapi
* /api/ombi/requests:
* get:
* tags: [Ombi]
* summary: Get Ombi requests
* description: |
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
* (filtered by Emby user mapping), while admins see all requests.
*
* Supports server-side filtering by media type, request status, title search,
* and sorting by requested date or title.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* parameters:
* - name: type
* in: query
* schema:
* type: array
* items:
* type: string
* enum: [movie, tv, all]
* default: [all]
* description: Filter by media type. Omit or use `all` for both.
* style: form
* explode: true
* - name: status
* in: query
* schema:
* type: array
* items:
* type: string
* enum: [pending, approved, available, denied]
* description: Filter by request status. Omit for all statuses.
* style: form
* explode: true
* - name: sort
* in: query
* schema:
* type: string
* enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc]
* default: requestedDate_desc
* description: Sort mode.
* - name: search
* in: query
* schema:
* type: string
* description: Case-insensitive substring match on title.
* - name: showAll
* in: query
* schema:
* type: string
* enum: ['true', 'false']
* description: Admin only. Show all users' requests.
* responses:
* '200':
* description: Ombi requests retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* type: string
* example: "username"
* isAdmin:
* type: boolean
* example: false
* showAll:
* type: boolean
* example: false
* requests:
* type: object
* properties:
* movie:
* type: array
* items:
* $ref: '#/components/schemas/OmbiRequest'
* tv:
* type: array
* items:
* $ref: '#/components/schemas/OmbiRequest'
* total:
* type: integer
* example: 5
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/requests', requireAuth, async (req, res) => {
try {
const user = req.user;
const isAdmin = user.isAdmin;
const username = user.name;
const showAll = isAdmin && req.query.showAll === 'true';
const arrRetrieverRegistry = require('../utils/arrRetrievers');
// initialize() is idempotent - cheap no-op if already initialized
await arrRetrieverRegistry.initialize();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
// Filter by user if not admin or if showAll is false
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
// Tag with mediaType and flatten for filtering/sorting
const allRequests = [
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
];
// Parse query params
let types = req.query.type;
let statuses = req.query.status;
const sort = req.query.sort || 'requestedDate_desc';
const search = req.query.search || '';
// Normalise to arrays
if (typeof types === 'string') types = [types];
if (typeof statuses === 'string') statuses = [statuses];
// Apply filters and sorting
const filtered = applyRequestFilters(allRequests, { types, statuses, sort, search });
// Split back into movie/tv
const movie = filtered.filter(r => r.mediaType === 'movie');
const tv = filtered.filter(r => r.mediaType === 'tv');
const total = filtered.length;
res.json({
user: username,
isAdmin,
showAll,
requests: { movie, tv },
total
});
} catch (error) {
logToFile(`[Ombi] Error fetching requests: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi requests' });
}
});
/**
* @openapi
* /api/ombi/webhook/enable:
* post:
* tags: [Ombi]
* summary: Enable Ombi webhook
* description: |
* Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
*
* **Authentication:** Requires cookie authentication.
* **CSRF:** Requires X-CSRF-Token header matching csrf_token cookie.
* security:
* - cookieAuth: []
* requestBody:
* required: false
* responses:
* '200':
* description: Webhook enabled successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* webhookUrl:
* type: string
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* example: "your-ombi-api-key"
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/enable', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Call Ombi API to register webhook
const axios = require('axios');
// Get existing settings to retrieve the database ID
const currentRes = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
).catch(err => {
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
return { data: {} };
});
const currentConfig = currentRes.data || {};
const settingsId = currentConfig.id || 0;
const response = await axios.post(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
id: settingsId,
enabled: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
},
{
headers: {
'ApiKey': ombiInst.apiKey,
'Content-Type': 'application/json'
}
}
);
logToFile(`[Ombi] Webhook enabled: ${webhookUrl}`);
res.json({
success: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
});
} catch (error) {
logToFile(`[Ombi] Error enabling webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to enable Ombi webhook' });
}
});
/**
* @openapi
* /api/ombi/webhook/status:
* get:
* tags: [Ombi]
* summary: Get Ombi webhook status
* description: |
* Returns the current Ombi webhook configuration status and metrics.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* responses:
* '200':
* description: Webhook status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* example: true
* webhookUrl:
* type: string
* nullable: true
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* nullable: true
* example: "your-ombi-api-key"
* triggers:
* type: object
* properties:
* requestAvailable:
* type: boolean
* example: true
* requestApproved:
* type: boolean
* example: true
* requestDeclined:
* type: boolean
* example: true
* requestPending:
* type: boolean
* example: true
* requestProcessing:
* type: boolean
* example: true
* stats:
* type: object
* properties:
* eventsReceived:
* type: integer
* example: 10
* pollsSkipped:
* type: integer
* example: 5
* lastWebhookTimestamp:
* type: integer
* example: 1716326400000
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/webhook/status', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
// Webhooks require SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET to be configured
if (!sofarrBaseUrl || !webhookSecret) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInst = ombiInstances[0];
// Call Ombi API to get webhook status
const axios = require('axios');
const response = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
);
const webhookConfig = response.data;
// Get webhook metrics from cache
const metrics = cache.getWebhookMetrics(ombiInst.url);
res.json({
enabled: webhookConfig.enabled || false,
webhookUrl: webhookConfig.webhookUrl || null,
applicationToken: webhookConfig.applicationToken || null,
// Note: Ombi may support per-trigger toggles, but we currently treat
// them as all-on or all-off based on webhookConfig.enabled
triggers: {
requestAvailable: webhookConfig.enabled || false,
requestApproved: webhookConfig.enabled || false,
requestDeclined: webhookConfig.enabled || false,
requestPending: webhookConfig.enabled || false,
requestProcessing: webhookConfig.enabled || false
},
stats: metrics ? {
eventsReceived: metrics.eventsReceived || 0,
pollsSkipped: metrics.pollsSkipped || 0,
lastWebhookTimestamp: metrics.lastWebhookTimestamp || null
} : null
});
} catch (error) {
logToFile(`[Ombi] Error fetching webhook status: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi webhook status' });
}
});
/**
* @openapi
* /api/ombi/webhook/test:
* post:
* tags: [Ombi]
* summary: Test Ombi webhook
* description: |
* Sends a test webhook event to the Sofarr Ombi webhook endpoint.
*
* **Authentication:** Requires cookie authentication and CSRF token.
* security:
* - cookieAuth: []
* - CsrfToken: []
* requestBody:
* required: false
* responses:
* '200':
* description: Test webhook sent successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/test', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Simulate a test webhook event
const axios = require('axios');
await axios.post(webhookUrl, {
notificationType: 'RequestAvailable',
requestId: 0,
requestedUser: 'test',
title: 'Test Request',
type: 'Movie',
requestStatus: 'Pending'
}, {
headers: {
'X-Sofarr-Webhook-Secret': webhookSecret,
'Content-Type': 'application/json'
}
});
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
res.json({ success: true });
} catch (error) {
logToFile(`[Ombi] Error testing webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to test Ombi webhook' });
}
});
module.exports = router;
+14 -310
View File
@@ -1,352 +1,56 @@
// 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 { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
// Helper to get first Radarr instance (for notification proxy routes)
function getFirstRadarrInstance() {
const instances = getRadarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
/**
* @openapi
* /api/radarr/queue:
* get:
* tags: [Radarr]
* summary: Get Radarr queue
* description: Proxy to Radarr's queue endpoint. Requires authentication and CSRF token.
* security:
* - CookieAuth: []
* - CsrfToken: []
* responses:
* '200':
* description: Queue data from Radarr
* content:
* application/json:
* schema:
* type: object
* '500':
* description: Proxy error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.use(requireAuth);
const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/queue`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
}
});
/**
* @openapi
* /api/radarr/history:
* get:
* tags: [Radarr]
* summary: Get Radarr history
* description: Proxy to Radarr's history endpoint. Requires authentication.
* security:
* - CookieAuth: []
* parameters:
* - name: pageSize
* in: query
* schema:
* type: integer
* default: 50
* description: Number of records per page
* responses:
* '200':
* description: History data from Radarr
* content:
* application/json:
* schema:
* type: object
*/
// Get history
router.get('/history', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/history`, {
headers: { 'X-Api-Key': instance.apiKey },
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': 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: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
}
});
// Get movie details
router.get('/movies/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
}
});
// Get all movies with tags
router.get('/movies', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/movie`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
}
});
/**
* @openapi
* /api/radarr/notifications/sofarr-webhook:
* post:
* tags: [Radarr]
* summary: Configure Sofarr webhook
* description: One-click setup for Sofarr webhook notification in Radarr. Requires authentication and CSRF token.
* security:
* - CookieAuth: []
* - CsrfToken: []
* responses:
* '200':
* description: Configured notification
* content:
* application/json:
* schema:
* type: object
* '400':
* description: Missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '503':
* description: Radarr not configured
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications - create notification
router.post('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
}
});
// PUT /api/radarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/radarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Radarr] Test response status:', error.response.status);
console.error('[Radarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Radarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Radarr] Response status:', error.response.status);
console.error('[Radarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
}
});
+10 -77
View File
@@ -1,107 +1,40 @@
// 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 { getSABnzbdInstances } = require('../utils/config');
// Helper to get first SABnzbd instance
function getFirstSABnzbdInstance() {
const instances = getSABnzbdInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
/**
* @openapi
* /api/sabnzbd/queue:
* get:
* tags: [SABnzbd]
* summary: Get SABnzbd queue
* description: Proxy to SABnzbd's queue endpoint. Requires authentication.
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Queue data from SABnzbd
* content:
* application/json:
* schema:
* type: object
* '500':
* description: Proxy error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.use(requireAuth);
// GET /api/sabnzbd/queue
// Get current queue
router.get('/queue', async (req, res) => {
const instance = getFirstSABnzbdInstance();
if (!instance) {
return res.status(503).json({ error: 'SABnzbd not configured' });
}
try {
const response = await axios.get(`${instance.url}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'queue',
apikey: instance.apiKey,
apikey: SABNZBD_API_KEY,
output: 'json'
}
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
}
});
/**
* @openapi
* /api/sabnzbd/history:
* get:
* tags: [SABnzbd]
* summary: Get SABnzbd history
* description: Proxy to SABnzbd's history endpoint. Requires authentication.
* security:
* - CookieAuth: []
* parameters:
* - name: limit
* in: query
* schema:
* type: integer
* default: 50
* description: Number of history records to return
* responses:
* '200':
* description: History data from SABnzbd
* content:
* application/json:
* schema:
* type: object
*/
// GET /api/sabnzbd/history
// Get history
router.get('/history', async (req, res) => {
const instance = getFirstSABnzbdInstance();
if (!instance) {
return res.status(503).json({ error: 'SABnzbd not configured' });
}
try {
const response = await axios.get(`${instance.url}/api`, {
const response = await axios.get(`${SABNZBD_URL}/api`, {
params: {
mode: 'history',
apikey: instance.apiKey,
apikey: 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: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
}
});
+14 -310
View File
@@ -1,352 +1,56 @@
// 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 { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
// Helper to get first Sonarr instance (for notification proxy routes)
function getFirstSonarrInstance() {
const instances = getSonarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
/**
* @openapi
* /api/sonarr/queue:
* get:
* tags: [Sonarr]
* summary: Get Sonarr queue
* description: Proxy to Sonarr's queue endpoint. Requires authentication and CSRF token.
* security:
* - CookieAuth: []
* - CsrfToken: []
* responses:
* '200':
* description: Queue data from Sonarr
* content:
* application/json:
* schema:
* type: object
* '500':
* description: Proxy error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.use(requireAuth);
const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
// Get queue
router.get('/queue', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/queue`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
}
});
/**
* @openapi
* /api/sonarr/history:
* get:
* tags: [Sonarr]
* summary: Get Sonarr history
* description: Proxy to Sonarr's history endpoint. Requires authentication.
* security:
* - CookieAuth: []
* parameters:
* - name: pageSize
* in: query
* schema:
* type: integer
* default: 50
* description: Number of records per page
* responses:
* '200':
* description: History data from Sonarr
* content:
* application/json:
* schema:
* type: object
*/
// Get history
router.get('/history', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/history`, {
headers: { 'X-Api-Key': instance.apiKey },
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': 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: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
}
});
// Get series details
router.get('/series/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
}
});
// Get all series with tags
router.get('/series', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/series`, {
headers: { 'X-Api-Key': instance.apiKey }
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
}
});
/**
* @openapi
* /api/sonarr/notifications/sofarr-webhook:
* post:
* tags: [Sonarr]
* summary: Configure Sofarr webhook
* description: One-click setup for Sofarr webhook notification in Sonarr. Requires authentication and CSRF token.
* security:
* - CookieAuth: []
* - CsrfToken: []
* responses:
* '200':
* description: Configured notification
* content:
* application/json:
* schema:
* type: object
* '400':
* description: Missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '503':
* description: Sonarr not configured
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications - create notification
router.post('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
}
});
// PUT /api/sonarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/sonarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Sonarr] Test response status:', error.response.status);
console.error('[Sonarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Sonarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Sonarr] Response status:', error.response.status);
console.error('[Sonarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
}
});
-175
View File
@@ -1,175 +0,0 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
/**
* @openapi
* /api/status/status:
* get:
* tags: [Status]
* summary: Get server status (admin-only)
* description: |
* Admin-only endpoint returning server metrics, cache statistics, polling information,
* and webhook metrics. Used by the admin status panel to monitor sofarr health.
*
* **Authentication:** Requires valid `emby_user` cookie (admin only).
*
* **Response Structure:**
* - `server`: Uptime, Node version, memory usage
* - `polling`: Polling enabled status, interval, last poll timings
* - `cache`: Cache statistics (item count, sizes, TTLs)
* - `webhooks`: Webhook configuration and metrics for Sonarr/Radarr
*
* **Webhook Metrics:**
* - `configured`: Whether webhook is configured in Sonarr/Radarr
* - `eventsReceived`: Total webhook events received
* - `lastWebhookTimestamp`: Last webhook event time
* - `pollsSkipped`: Number of poll cycles skipped due to recent webhook activity
*
* **x-integration-notes:** This endpoint is used by the admin status panel to display:
* - Server health and resource usage
* - Polling performance and timing
* - Cache hit rates and sizes
* - Webhook activity and smart polling effectiveness
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Status data
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/StatusResponse'
* example:
* server:
* uptimeSeconds: 3600
* nodeVersion: "v22.0.0"
* memoryUsageMB: 128.5
* heapUsedMB: 64.2
* heapTotalMB: 128.0
* polling:
* enabled: true
* intervalMs: 5000
* lastPoll:
* sabnzbdQueue: 150
* sonarrQueue: 200
* cache:
* "poll:sab-queue":
* size: 2456
* items: 1
* ttlRemaining: 12000
* webhooks:
* sonarr:
* configured: true
* eventsReceived: 42
* lastWebhookTimestamp: "2026-05-21T10:00:00.000Z"
* pollsSkipped: 15
* radarr:
* configured: true
* eventsReceived: 38
* lastWebhookTimestamp: "2026-05-21T09:55:00.000Z"
* pollsSkipped: 12
* '403':
* description: Admin access required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Admin access required"
* '500':
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET http://localhost:3001/api/status/status \
* -b cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/status/status', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
* console.log('Uptime:', data.server.uptimeSeconds);
*/
router.get('/', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const cacheStats = cache.getStats();
const uptime = process.uptime();
// Get webhook metrics
const webhookMetrics = getGlobalWebhookMetrics();
// Check webhook configuration for each service
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const ombiInstances = getOmbiInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
const ombiWebhookConfigured = ombiInstances.length > 0
? await checkOmbiWebhookConfigured(ombiInstances[0])
: false;
// Find Sonarr, Radarr, and Ombi metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
const ombiMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
ombiMetrics[url] = metrics;
}
}
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
nodeVersion: process.version,
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
},
polling: {
enabled: POLLING_ENABLED,
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
lastPoll: getLastPollTimings()
},
cache: cacheStats,
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More