Compare commits
312 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 | |||
| 0eaa54cf4a | |||
| 865cf1f57a | |||
| ff5f50cc3a | |||
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 | |||
| 8fb00843ef | |||
| d2ac7731ca | |||
| 6f6aa5b967 | |||
| 5390bbf615 | |||
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c | |||
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 | |||
| d4ee3b8ef7 | |||
| 64c872423f | |||
| 86c67bcf29 | |||
| 9548eb41f5 | |||
| d1db3118f0 | |||
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e | |||
| a021ceba47 | |||
| f8c7e35f31 | |||
| de71580756 | |||
| 2943afdbaf | |||
| 1d571b066d | |||
| db809f2fb3 | |||
| 9d91d85514 | |||
| f52a687a46 | |||
| e3f90d54f4 | |||
| a006cb4a37 | |||
| 4ddd3036d9 | |||
| 4cd9faaf25 | |||
| 2e9fe8e049 | |||
| 12c44a611e | |||
| 614af9eb44 | |||
| b77c0d6ec0 | |||
| 44c553709c | |||
| 8376aa0c0b | |||
| e8a149427a | |||
| 548aca6bee | |||
| 4aa3590017 | |||
| d3d085d614 | |||
| dbf45ec31d | |||
| f1e0a77fad | |||
| 9862c0555c | |||
| 26d9e429a9 | |||
| 1dccda529a | |||
| a85747a4c5 | |||
| 884fb5285f | |||
| e8037afbb8 | |||
| 4d860dc787 | |||
| ecaedbaf6a | |||
| 9621aec453 | |||
| ed4237debb | |||
| de9a9284dc | |||
| 52a75fd8cb | |||
| 4941b69924 | |||
| 37bed1cd4e | |||
| 1a4ff73067 | |||
| afa6ebc3c7 | |||
| 1ed01d0ef0 | |||
| f3e1bd17fb | |||
| bcdbbec804 | |||
| db9b3e7a30 | |||
| e254873bee | |||
| 7dadb849f6 | |||
| 6980558ca9 | |||
| a141bb57d6 | |||
| 43f5a52749 | |||
| 5c0ad7cb1b | |||
| a21bafa041 | |||
| 12effe17d3 | |||
| 1bb9e4014e | |||
| 964dacc588 | |||
| 777fa26e5b | |||
| 93a8c3fd2e | |||
| 9aca9c45e2 | |||
| 5d0da45e10 | |||
| adbb0c12c1 | |||
| 9a4408e797 | |||
| 05cf5a0993 | |||
| bb10cd4aef | |||
| 251c10f08c | |||
| 474ae949a9 | |||
| 084cb0579e | |||
| 93a09e10a8 | |||
| 47817d057b | |||
| f6ad7c85bf | |||
| a349c8e2cf | |||
| f2b44f65af | |||
| b3664747cb | |||
| 14b47ce410 | |||
| 5ec5484b91 | |||
| 05c9527189 | |||
| f461c3669c | |||
| 0acd452ebd | |||
| dbdfe3f329 | |||
| b9b5d7d393 | |||
| 7424e70ea6 | |||
| 830dea3d6b | |||
| 4ff462b7f4 | |||
| d9f1fc99a9 | |||
| 46b42045f1 | |||
| d12356e8f3 | |||
| 6124ec0f5a | |||
| fd303699db | |||
| 8f19da3ae6 | |||
| 3c9dd3ca62 | |||
| f02c30efde | |||
| ddebe96056 | |||
| b6367076f9 | |||
| 31ed9f02b6 | |||
| 3e6af1bff2 | |||
| d9897ff0d2 | |||
| 06442c1d75 | |||
| 86aaa79339 | |||
| e2a71e65a1 | |||
| d03efbf25e | |||
| 0b91152ad7 | |||
| 8dc105ff3e | |||
| a38fc4a8ce | |||
| 2bf4cb2a0f | |||
| d74b46d5b0 | |||
| 9cffb96f29 | |||
| 4d61dd566f | |||
| d568800942 | |||
| 7d3e6e6a47 | |||
| ee2f275501 | |||
| ca6ff66115 | |||
| 080431c4b7 | |||
| f457a708d2 | |||
| 914ab73d4e | |||
| 25d8e007a4 | |||
| bb7b66e06d | |||
| 5ad525a760 | |||
| 1e162381f4 | |||
| 42f0481a9a | |||
| ddad80a666 | |||
| e772001c3f | |||
| 1f10414498 | |||
| 1e3926b206 | |||
| 5fde69fcf5 | |||
| a562cfe9aa | |||
| 8549746721 | |||
| 63fc370262 | |||
| 6362441dd5 | |||
| 76f9e87b44 | |||
| 8c461de72a | |||
| d11f11be69 | |||
| 05d11975e6 | |||
| cd3480c0ce | |||
| 712c98d817 | |||
| ff7ace9f4f | |||
| 73500751a0 | |||
| 82a9df134b | |||
| 67fa79796b | |||
| f06d945358 | |||
| f5883d4929 | |||
| 80cf3eaa39 | |||
| 1ab7e52167 | |||
| 544c168b82 | |||
| 747a14ebd3 | |||
| 49d66c07ee | |||
| be791ed044 | |||
| 7195a09562 | |||
| 720de6688b | |||
| 3e06bdf8cd | |||
| ca1c136d4f | |||
| a04f2c9b25 | |||
| 743b169989 | |||
| 794cb7268e | |||
| d310d101ed | |||
| 96f24eb3b7 | |||
| abcb9bfded | |||
| e5920b207f | |||
| d3483f3be7 | |||
| 252cc50aa4 | |||
| 57908e2b9e | |||
| e2757768c7 | |||
| 2469c3e3f4 | |||
| 6c8c333c6a | |||
| 5dfe0b1216 | |||
| 77beef787f | |||
| 235a866ec8 | |||
| f1d9de2a92 | |||
| 9d0e31ec9a | |||
| 42c3eebf18 | |||
| f295e1c90d | |||
| c5e8281440 | |||
| f22dd0d1f6 | |||
| 5159a83475 | |||
| ccc3b6ffec | |||
| 4ec7d734b8 | |||
| 2e85fae57a | |||
| aeacadbe68 | |||
| 3ef35a8c43 | |||
| 0f3c02e52d | |||
| 9fd60bcfed | |||
| af58e1bf2a | |||
| 2d04402284 | |||
| 0310f10e5d | |||
| 5ab8cc96a3 | |||
| a7363fcb3a | |||
| d06e24dbb6 | |||
| 6df94e5ad2 | |||
| 015e07ae7a | |||
| eeab314a08 | |||
| 603f444c33 | |||
| 740b03ac85 | |||
| 917939a9fc | |||
| 575688dab7 | |||
| 3747dab36f | |||
| 76f0aad453 | |||
| 67ab378d31 | |||
| 1bef14d590 | |||
| 8609f03c5a | |||
| fcb0cd8e4a | |||
| 80e8b72878 | |||
| e022db8ef5 | |||
| 1d61ea8d83 | |||
| 99ddb05dbe | |||
| 934f5e3fd5 | |||
| 21befa5356 | |||
| 84658102e0 | |||
| 6529702f73 | |||
| 6e199925aa | |||
| 627329df2f | |||
| fa0e9a93af | |||
| 9343486705 | |||
| 5342170ced | |||
| cc0e34b3d1 | |||
| e39f15d3d8 | |||
| bbcbf8d0f7 | |||
| 620f264861 | |||
| a50e5a7d69 | |||
| f095e6a2d1 | |||
| bf3e1c353d | |||
| c85ff602d0 | |||
| d73e1dcf0b | |||
| 0a54d0d302 | |||
| ae9e877445 | |||
| 853b205c46 | |||
| 8c4cc20551 | |||
| da77f083fe | |||
| 71feaf0175 | |||
| 65b9f0f395 | |||
| b41f943407 | |||
| 9debd77392 | |||
| 20dfe06866 | |||
| a0f630fb81 | |||
| e640215502 | |||
| 972b407956 | |||
| cf7008fd54 | |||
| 2747ca7754 | |||
| 0341540751 | |||
| 3bb9e936c3 | |||
| aef21d1b50 | |||
| a6fcde58cf | |||
| d839fa98a0 | |||
| a92ab85bc0 | |||
| 57b127ea95 | |||
| 56f42755cc | |||
| 15152714fd | |||
| 19b9c97e64 | |||
| 55a5577f2a | |||
| 6139095444 | |||
| 4c9985e01a | |||
| fecb96b04e | |||
| c98b81c8bd | |||
| 90bf411e0c | |||
| 867e86615e | |||
| 2cbe3c6b76 | |||
| 59adcbc36e |
@@ -1,3 +1,4 @@
|
||||
# Docker build context ignores
|
||||
node_modules/
|
||||
.env
|
||||
.env.example
|
||||
@@ -7,7 +8,8 @@ node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
client/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
@@ -19,6 +19,43 @@ LOG_LEVEL=info
|
||||
# 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
|
||||
|
||||
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
|
||||
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
|
||||
# Useful if those services reside in the same local network/docker container setup and
|
||||
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
|
||||
# Example: http://sofarr:3001 or http://192.168.1.50:3001
|
||||
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
|
||||
|
||||
# --- 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
|
||||
# =============================================================================
|
||||
@@ -94,6 +131,17 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
|
||||
# QBITTORRENT_USERNAME=admin
|
||||
# QBITTORRENT_PASSWORD=your-password
|
||||
|
||||
# =============================================================================
|
||||
# RTORRENT_INSTANCES (JSON Array)
|
||||
# The url MUST include the full XML-RPC endpoint path.
|
||||
# Standard/self-hosted installs: .../RPC2
|
||||
# whatbox.ca users: .../xmlrpc
|
||||
# Other installations may use different custom paths.
|
||||
# Example:
|
||||
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent.local:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
|
||||
# For whatbox.ca:
|
||||
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
|
||||
|
||||
# =============================================================================
|
||||
# SONARR INSTANCES (JSON Array Format)
|
||||
# Add one or more Sonarr instances as a single-line JSON array
|
||||
@@ -116,6 +164,15 @@ 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
|
||||
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
|
||||
# to resolve the race condition where Ombi fires the webhook before committing to its database.
|
||||
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
|
||||
|
||||
# =============================================================================
|
||||
# NOTES
|
||||
# =============================================================================
|
||||
@@ -125,4 +182,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# 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).
|
||||
# =============================================================================
|
||||
|
||||
@@ -4,7 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
- 'develop'
|
||||
- 'develop*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -20,18 +24,31 @@ jobs:
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$BRANCH" = "develop" ]; then
|
||||
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image (version ${VERSION})"
|
||||
if [[ "$BRANCH" == develop* ]]; then
|
||||
# Sanitise branch name for tag: replace slashes with dashes
|
||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image tags: ${TAGS}"
|
||||
else
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||
|
||||
# Gitea package registry 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 ${VERSION} from branch ${BRANCH}"
|
||||
echo "Building release image tags: ${TAGS}"
|
||||
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:
|
||||
|
||||
@@ -2,9 +2,13 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches: ["**", "!release/**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
branches: ["**", "!release/**"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
@@ -60,3 +64,54 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -7,16 +7,24 @@ on:
|
||||
- "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: Dependency licence compatibility
|
||||
name: Licence compatibility and copyright header verification
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
@@ -32,7 +40,59 @@ jobs:
|
||||
|
||||
- name: Check licence compatibility
|
||||
run: |
|
||||
npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--excludePrivatePackages \
|
||||
&& echo "All production dependency licences are compatible with MIT."
|
||||
# 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
|
||||
|
||||
@@ -10,3 +10,6 @@ data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.agents/
|
||||
.windsurf/
|
||||
scratch/
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -4,6 +4,567 @@ 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.28] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||
|
||||
## [1.7.27] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||
|
||||
## [1.7.26] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing Ombi & *Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and *Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||
|
||||
## [1.7.25] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
|
||||
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
|
||||
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
|
||||
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
|
||||
|
||||
## [1.7.24] - 2026-05-27
|
||||
|
||||
### Enhanced
|
||||
|
||||
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
|
||||
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
|
||||
|
||||
## [1.7.23] - 2026-05-27
|
||||
|
||||
### Enhanced
|
||||
|
||||
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
|
||||
|
||||
## [1.7.22] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
|
||||
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
|
||||
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
|
||||
|
||||
### Added
|
||||
|
||||
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
|
||||
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
|
||||
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
|
||||
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.21] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
|
||||
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.21] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
|
||||
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.20] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.19] - 2026-05-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
|
||||
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.18] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.17] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.16] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.15] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.14] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.12] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
|
||||
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
|
||||
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
|
||||
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
|
||||
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
|
||||
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.11] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||
|
||||
---
|
||||
|
||||
## [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 1–5.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
|
||||
|
||||
@@ -9,6 +9,18 @@ WORKDIR /app
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -33,6 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
# 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.
|
||||
|
||||
@@ -4,39 +4,76 @@
|
||||
|
||||
**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)
|
||||
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent, Transmission, rTorrent)
|
||||
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
|
||||
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
|
||||
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
||||
- **Multi-Instance Support** - Connect to multiple instances of each service
|
||||
- **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 │────▶│ SABnzbd (Usenet downloads) │
|
||||
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
|
||||
└─────────────┘ └──────────────┘ │ Sonarr (TV management) │
|
||||
│ │ Radarr (Movie management) │
|
||||
│ │ Emby (User authentication) │
|
||||
▼ └─────────────────────────────┘
|
||||
┌──────────────┐
|
||||
│ Dashboard │
|
||||
│ Aggregator │
|
||||
└──────────────┘
|
||||
┌─────────────┐ ┌──────────────────────────────────────────────┐
|
||||
│ 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)
|
||||
```
|
||||
|
||||
**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) are matched by title to that activity
|
||||
- Downloads (from SABnzbd, qBittorrent, Transmission, or rTorrent) are matched by title to that activity
|
||||
- Only your downloads appear on your dashboard
|
||||
|
||||
### Multi-Instance Support
|
||||
@@ -52,10 +89,11 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** (recommended), or Node.js (v22+) for manual installation
|
||||
- At least one of: SABnzbd or qBittorrent
|
||||
- At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
|
||||
- Sonarr (optional, for TV tracking)
|
||||
- Radarr (optional, for movie tracking)
|
||||
- Emby (for user authentication)
|
||||
- Ombi (optional, for request management integration)
|
||||
|
||||
## Docker Deployment (Recommended)
|
||||
|
||||
@@ -107,6 +145,8 @@ 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
|
||||
@@ -130,6 +170,8 @@ 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
|
||||
```
|
||||
@@ -185,8 +227,36 @@ PORT=3001 # Server port
|
||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
|
||||
# Debug Log Streaming Subsystem
|
||||
ENABLE_LOG_STREAM=false # Set to true to enable logs debugging routes
|
||||
LOG_ALLOW_SUBNETS=127.0.0.1/32 # Comma-separated allowed CIDR blocks (e.g. 127.0.0.1/32,192.168.1.0/24)
|
||||
```
|
||||
|
||||
### 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:
|
||||
@@ -198,10 +268,21 @@ SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey
|
||||
# qBittorrent Instances (uses username/password, not API key)
|
||||
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
|
||||
|
||||
# Transmission Instances (uses username/password)
|
||||
TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091/transmission/rpc","username":"admin","password":"pass"}]
|
||||
|
||||
# rTorrent Instances (uses username/password, URL must include full RPC endpoint)
|
||||
# Standard installs use /RPC2. Some providers like whatbox.ca use /xmlrpc.
|
||||
# No path is automatically appended - always include the full RPC endpoint.
|
||||
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
|
||||
|
||||
# For whatbox.ca (example):
|
||||
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
|
||||
|
||||
# Sonarr Instances
|
||||
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
|
||||
|
||||
# Radarr Instances
|
||||
# Radarr Instances
|
||||
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
|
||||
|
||||
# Emby (single instance for authentication)
|
||||
@@ -215,8 +296,45 @@ 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:
|
||||
@@ -265,6 +383,49 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
- **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).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
@@ -279,6 +440,38 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
- `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
|
||||
|
||||
### Debug Logs (requires ENABLE_LOG_STREAM=true)
|
||||
- `GET /api/debug/status` — Get runtime log stream configurations (public)
|
||||
- `GET /api/debug/server-logs` — **SSE stream**: push server `stdout`/`stderr` in real-time (requires auth & subnet check)
|
||||
- `GET /api/debug/client-logs` — **SSE stream**: push ingested frontend console logs in real-time (requires auth & subnet check)
|
||||
- `POST /api/debug/client-logs` — Ingest batched frontend console logs (requires auth & subnet check)
|
||||
|
||||
### 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
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
@@ -323,7 +516,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
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
|
||||
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 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 |
|
||||
|
||||
@@ -35,6 +40,13 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
| 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 or `secret` query parameter; 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +61,15 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
- [ ] 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
|
||||
@@ -141,10 +162,13 @@ server {
|
||||
|
||||
## 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 |
|
||||
| Endpoint | Limit | Details & Exemptions |
|
||||
|----------|-------|----------------------|
|
||||
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
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;
|
||||
@@ -0,0 +1,353 @@
|
||||
// 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,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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';
|
||||
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize client console log capturing early
|
||||
initClientLogCapture();
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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');
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
// 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,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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 '';
|
||||
|
||||
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||
const userSource = request.requestedUser || request.RequestedUser ||
|
||||
request.user || request.User ||
|
||||
request.requestedBy || request.RequestedBy ||
|
||||
request.ombiUser || request.OmbiUser ||
|
||||
request.requestedByUser || request.RequestedByUser;
|
||||
|
||||
// If userSource is an object, extract key fields
|
||||
if (userSource && typeof userSource === 'object') {
|
||||
const username = userSource.alias || userSource.Alias ||
|
||||
userSource.userAlias || userSource.UserAlias ||
|
||||
userSource.userName || userSource.UserName ||
|
||||
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||
userSource.displayName || userSource.DisplayName ||
|
||||
userSource.email || userSource.Email;
|
||||
if (username) return username;
|
||||
}
|
||||
|
||||
// If userSource is a string
|
||||
if (userSource && typeof userSource === 'string') {
|
||||
return userSource;
|
||||
}
|
||||
|
||||
// Fallbacks on the request root level
|
||||
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||
request.requestedByUsername || request.RequestedByUsername ||
|
||||
request.requester || request.Requester ||
|
||||
request.requestedByEmail || request.RequestedByEmail;
|
||||
if (rootFallback) return rootFallback;
|
||||
|
||||
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||
if (Array.isArray(request.seasons)) {
|
||||
for (const season of request.seasons) {
|
||||
const seasonUser = extractRequestedUser(season);
|
||||
if (seasonUser) return seasonUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(request.childRequests)) {
|
||||
for (const child of request.childRequests) {
|
||||
const childUser = extractRequestedUser(child);
|
||||
if (childUser) return childUser;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
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);
|
||||
const user = document.createElement('span');
|
||||
user.className = 'request-user';
|
||||
if (username) {
|
||||
user.textContent = `Requested by: ${username}`;
|
||||
} else {
|
||||
user.textContent = 'Requested by: Unknown (Ombi)';
|
||||
user.title = 'No user information received from Ombi';
|
||||
user.style.cursor = 'help';
|
||||
user.style.textDecoration = 'underline dotted';
|
||||
}
|
||||
meta.appendChild(user);
|
||||
|
||||
const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
|
||||
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
|
||||
if (dateStr) {
|
||||
const requestDate = document.createElement('span');
|
||||
requestDate.className = 'request-date';
|
||||
try {
|
||||
const dateObj = new Date(dateStr);
|
||||
if (!isNaN(dateObj.getTime())) {
|
||||
const year = dateObj.getFullYear();
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||
const hours = String(dateObj.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
|
||||
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
} else {
|
||||
requestDate.textContent = `Date: ${dateStr}`;
|
||||
}
|
||||
} catch (e) {
|
||||
requestDate.textContent = `Date: ${dateStr}`;
|
||||
}
|
||||
meta.appendChild(requestDate);
|
||||
}
|
||||
|
||||
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('span');
|
||||
actions.className = 'service-icons-container';
|
||||
|
||||
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||
if (state.ombiBaseUrl && id) {
|
||||
const ombiLink = document.createElement('a');
|
||||
ombiLink.className = 'ombi-link';
|
||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
|
||||
ombiLink.target = '_blank';
|
||||
ombiLink.title = 'View in Ombi';
|
||||
|
||||
const ombiIcon = document.createElement('img');
|
||||
ombiIcon.className = 'service-icon ombi';
|
||||
ombiIcon.src = '/images/ombi.svg';
|
||||
ombiIcon.alt = 'Ombi';
|
||||
|
||||
ombiLink.appendChild(ombiIcon);
|
||||
actions.appendChild(ombiLink);
|
||||
}
|
||||
|
||||
if (state.isAdmin && request.arrLink) {
|
||||
const arrLink = document.createElement('a');
|
||||
arrLink.className = `${request.arrType}-link`;
|
||||
arrLink.href = request.arrLink;
|
||||
arrLink.target = '_blank';
|
||||
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
|
||||
|
||||
const arrIcon = document.createElement('img');
|
||||
arrIcon.className = `service-icon ${request.arrType}`;
|
||||
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||
|
||||
arrLink.appendChild(arrIcon);
|
||||
actions.appendChild(arrLink);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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">×</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;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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() || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
|
||||
export function initThemeSwitcher() {
|
||||
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||
const currentTheme = getTheme() || 'light';
|
||||
|
||||
// Set initial active state on buttons
|
||||
themeButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme) {
|
||||
setTheme(theme);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
saveTheme(theme);
|
||||
|
||||
// Sync button active classes if elements are present on the page
|
||||
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||
themeButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-theme') === theme) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
const logQueue = [];
|
||||
const MAX_QUEUE_SIZE = 20;
|
||||
const FLUSH_INTERVAL_MS = 2000;
|
||||
|
||||
// Original console functions
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
let isSending = false;
|
||||
let isInitialized = false;
|
||||
let flushInterval = null;
|
||||
|
||||
function formatArgs(args) {
|
||||
return args.map(arg => {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (arg instanceof Error) {
|
||||
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function enqueue(level, args) {
|
||||
const formattedMsg = formatArgs(args);
|
||||
|
||||
// Still write to the developer console!
|
||||
if (level === 'info') originalLog.apply(console, args);
|
||||
else if (level === 'warn') originalWarn.apply(console, args);
|
||||
else if (level === 'error') originalError.apply(console, args);
|
||||
|
||||
// Guard against infinite loop during logs dispatching
|
||||
if (isSending) return;
|
||||
|
||||
logQueue.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formattedMsg
|
||||
});
|
||||
|
||||
// Flush immediately if queue is full
|
||||
if (logQueue.length >= MAX_QUEUE_SIZE) {
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushQueue() {
|
||||
if (logQueue.length === 0 || isSending) return;
|
||||
|
||||
isSending = true;
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
// keepalive allows request to survive page unload
|
||||
keepalive: true
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a fast/unblocked payload flush using sendBeacon on page unload
|
||||
function flushOnUnload() {
|
||||
if (logQueue.length === 0) return;
|
||||
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
||||
navigator.sendBeacon('/api/debug/client-logs', blob);
|
||||
} catch (err) {
|
||||
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
|
||||
try {
|
||||
fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch),
|
||||
keepalive: true
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initClientLogCapture() {
|
||||
if (isInitialized) return;
|
||||
|
||||
try {
|
||||
// 1. Check if the server toggle for logging is active
|
||||
const response = await fetch('/api/debug/status');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data && data.enabled === true) {
|
||||
// 2. Override global console methods
|
||||
console.log = (...args) => enqueue('info', args);
|
||||
console.warn = (...args) => enqueue('warn', args);
|
||||
console.error = (...args) => enqueue('error', args);
|
||||
|
||||
// 3. Set interval for batch updates
|
||||
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
||||
|
||||
// 4. Setup beforeunload listener for clean flushing
|
||||
window.addEventListener('beforeunload', flushOnUnload);
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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';
|
||||
|
||||
// Ombi TV requests store status flags inside childRequests
|
||||
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||
for (const child of request.childRequests) {
|
||||
if (child && child.available) return 'available';
|
||||
}
|
||||
for (const child of request.childRequests) {
|
||||
if (child && child.denied) return 'denied';
|
||||
}
|
||||
for (const child of request.childRequests) {
|
||||
if (child && child.approved) return 'approved';
|
||||
}
|
||||
for (const child of request.childRequests) {
|
||||
if (child && child.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;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../public',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
@@ -44,13 +44,17 @@ services:
|
||||
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
|
||||
read_only: true
|
||||
# Comment out for development when mounting code volumes
|
||||
# read_only: true
|
||||
tmpfs:
|
||||
- /tmp # Node.js needs a writable /tmp
|
||||
security_opt:
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# Adding a New Download Client to Sofarr
|
||||
|
||||
This guide explains how to add support for a new download client to Sofarr using the Pluggable Download Client Architecture (PDCA).
|
||||
|
||||
## Overview
|
||||
|
||||
The PDCA makes adding new download clients straightforward by providing a standardized interface. You only need to implement the `DownloadClient` abstract base class and register your client in the configuration system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Familiarity with JavaScript/Node.js
|
||||
- Understanding of your target client's API
|
||||
- Basic knowledge of Sofarr's architecture (see [ARCHITECTURE.md](ARCHITECTURE.md))
|
||||
|
||||
## Step 1: Create the Client Class
|
||||
|
||||
Create a new file in `server/clients/` named after your client (e.g., `DelugeClient.js`).
|
||||
|
||||
```javascript
|
||||
// server/clients/DelugeClient.js
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class DelugeClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
// Add any client-specific initialization here
|
||||
this.sessionId = null;
|
||||
this.rpcUrl = `${this.url}/json`;
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'deluge';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
// Implement connection test logic
|
||||
const response = await this.makeRequest('auth.check_session');
|
||||
logToFile(`[Deluge:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(method, params = []) {
|
||||
// Implement RPC call logic
|
||||
const payload = {
|
||||
method: method,
|
||||
params: params,
|
||||
id: Date.now()
|
||||
};
|
||||
|
||||
// Add authentication if needed
|
||||
if (this.sessionId) {
|
||||
payload.params.unshift(this.sessionId);
|
||||
}
|
||||
|
||||
// Make HTTP request to your client's API
|
||||
// Handle authentication, errors, etc.
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Fetch downloads from your client
|
||||
const torrents = await this.makeRequest('core.get_torrents_status',
|
||||
[{}, ['name', 'state', 'progress', 'total_size', 'download_payload_rate']]
|
||||
);
|
||||
|
||||
// Normalize each download using the standard schema
|
||||
return Object.entries(torrents).map(([id, torrent]) =>
|
||||
this.normalizeDownload({ ...torrent, id })
|
||||
);
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Error fetching downloads: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
// Optional: Return client status information
|
||||
const status = await this.makeRequest('core.get_session_status');
|
||||
return status;
|
||||
} catch (error) {
|
||||
logToFile(`[Deluge:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
// Convert client-specific data to the normalized schema
|
||||
return {
|
||||
id: torrent.id,
|
||||
title: torrent.name,
|
||||
type: 'torrent',
|
||||
client: 'deluge',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: this.mapStatus(torrent.state),
|
||||
progress: Math.round(torrent.progress * 100),
|
||||
size: torrent.total_size,
|
||||
downloaded: Math.round(torrent.total_size * torrent.progress),
|
||||
speed: torrent.download_payload_rate,
|
||||
eta: torrent.eta > 0 ? torrent.eta : null,
|
||||
category: torrent.label || undefined,
|
||||
tags: torrent.tracker ? [torrent.tracker] : [],
|
||||
savePath: torrent.save_path,
|
||||
addedOn: torrent.added_time ? new Date(torrent.added_time * 1000).toISOString() : undefined,
|
||||
raw: torrent // Include original data for advanced use cases
|
||||
};
|
||||
}
|
||||
|
||||
mapStatus(state) {
|
||||
// Map client-specific states to normalized statuses
|
||||
const statusMap = {
|
||||
'Downloading': 'Downloading',
|
||||
'Seeding': 'Seeding',
|
||||
'Paused': 'Paused',
|
||||
'Checking': 'Checking',
|
||||
'Error': 'Error',
|
||||
'Queued': 'Queued'
|
||||
};
|
||||
|
||||
return statusMap[state] || state;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DelugeClient;
|
||||
```
|
||||
|
||||
## Step 2: Add Configuration Support
|
||||
|
||||
Update `server/utils/config.js` to add support for your client's environment variables:
|
||||
|
||||
```javascript
|
||||
function getDelugeInstances() {
|
||||
return parseInstances(
|
||||
process.env.DELUGE_INSTANCES,
|
||||
process.env.DELUGE_URL,
|
||||
null, // no apiKey for Deluge
|
||||
process.env.DELUGE_USERNAME,
|
||||
process.env.DELUGE_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
// Add to module.exports
|
||||
module.exports = {
|
||||
// ... existing exports
|
||||
getDelugeInstances,
|
||||
// ... other exports
|
||||
};
|
||||
```
|
||||
|
||||
## Step 3: Register the Client
|
||||
|
||||
Update `server/utils/downloadClients.js` to include your client:
|
||||
|
||||
```javascript
|
||||
const DelugeClient = require('../clients/DelugeClient');
|
||||
|
||||
// Add to clientClasses mapping
|
||||
const clientClasses = {
|
||||
sabnzbd: SABnzbdClient,
|
||||
qbittorrent: QBittorrentClient,
|
||||
transmission: TransmissionClient,
|
||||
deluge: DelugeClient // Add your client here
|
||||
};
|
||||
|
||||
// Update instance configuration
|
||||
const instanceConfigs = [
|
||||
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
|
||||
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
|
||||
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
|
||||
...delugeInstances.map(inst => ({ ...inst, type: 'deluge' })) // Add this line
|
||||
];
|
||||
```
|
||||
|
||||
## Step 4: Update Poller Integration
|
||||
|
||||
The poller automatically uses the registry, so no changes are needed there. However, if you want to maintain backward compatibility with existing cache keys, you may need to update the poller's transformation logic.
|
||||
|
||||
## Step 5: Add Tests
|
||||
|
||||
Create comprehensive tests for your client:
|
||||
|
||||
```javascript
|
||||
// tests/unit/clients/DelugeClient.test.js
|
||||
const DelugeClient = require('../../../server/clients/DelugeClient');
|
||||
|
||||
describe('DelugeClient', () => {
|
||||
let client;
|
||||
let mockConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
id: 'test-deluge',
|
||||
name: 'Test Deluge',
|
||||
url: 'http://localhost:8112',
|
||||
username: 'admin',
|
||||
password: 'deluge'
|
||||
};
|
||||
|
||||
client = new DelugeClient(mockConfig);
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should initialize with correct properties', () => {
|
||||
expect(client.getClientType()).toBe('deluge');
|
||||
expect(client.getInstanceId()).toBe('test-deluge');
|
||||
expect(client.name).toBe('Test Deluge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Test', () => {
|
||||
it('should test connection successfully', async () => {
|
||||
// Mock successful connection
|
||||
client.makeRequest = jest.fn().mockResolvedValue({ result: true });
|
||||
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(client.makeRequest).toHaveBeenCalledWith('auth.check_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download Normalization', () => {
|
||||
it('should normalize download data correctly', () => {
|
||||
const torrent = {
|
||||
id: 'abc123',
|
||||
name: 'Test Torrent',
|
||||
state: 'Downloading',
|
||||
progress: 0.75,
|
||||
total_size: 1000000000,
|
||||
download_payload_rate: 1048576,
|
||||
eta: 3600,
|
||||
label: 'movies',
|
||||
save_path: '/downloads/test'
|
||||
};
|
||||
|
||||
const normalized = client.normalizeDownload(torrent);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
id: 'abc123',
|
||||
title: 'Test Torrent',
|
||||
type: 'torrent',
|
||||
client: 'deluge',
|
||||
instanceId: 'test-deluge',
|
||||
instanceName: 'Test Deluge',
|
||||
status: 'Downloading',
|
||||
progress: 75,
|
||||
size: 1000000000,
|
||||
downloaded: 750000000,
|
||||
speed: 1048576,
|
||||
eta: 3600,
|
||||
category: 'movies',
|
||||
tags: [],
|
||||
savePath: '/downloads/test',
|
||||
raw: torrent
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add more tests for error handling, edge cases, etc.
|
||||
});
|
||||
```
|
||||
|
||||
## Step 6: Configuration Examples
|
||||
|
||||
Add documentation for your client's configuration in `.env.sample`:
|
||||
|
||||
```bash
|
||||
# Deluge Configuration
|
||||
# Single instance (legacy format)
|
||||
# DELUGE_URL=http://localhost:8112
|
||||
# DELUGE_USERNAME=admin
|
||||
# DELUGE_PASSWORD=deluge
|
||||
|
||||
# Multiple instances (JSON format)
|
||||
DELUGE_INSTANCES='[
|
||||
{
|
||||
"name": "Main Deluge",
|
||||
"url": "http://localhost:8112",
|
||||
"username": "admin",
|
||||
"password": "deluge"
|
||||
},
|
||||
{
|
||||
"name": "Backup Deluge",
|
||||
"url": "http://localhost:8113",
|
||||
"username": "admin",
|
||||
"password": "deluge"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
## Step 7: Update Documentation
|
||||
|
||||
Update relevant documentation files:
|
||||
|
||||
1. **ARCHITECTURE.md**: Add your client to the download clients section
|
||||
2. **README.md**: Add configuration instructions for your client
|
||||
3. **CHANGELOG.md**: Document the new client support
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Always wrap API calls in try-catch blocks
|
||||
- Return empty arrays for download fetch failures
|
||||
- Log errors with appropriate context
|
||||
- Implement retry logic where appropriate
|
||||
|
||||
### Authentication
|
||||
|
||||
- Store credentials securely (don't log them)
|
||||
- Handle session expiration gracefully
|
||||
- Implement automatic re-authentication when possible
|
||||
|
||||
### Performance
|
||||
|
||||
- Use efficient API calls (batch requests when available)
|
||||
- Implement caching for expensive operations
|
||||
- Consider pagination for large download lists
|
||||
- Use connection pooling for HTTP clients
|
||||
|
||||
### Normalization
|
||||
|
||||
- Always return the complete normalized schema
|
||||
- Handle missing or null values gracefully
|
||||
- Preserve original data in the `raw` field
|
||||
- Map client-specific statuses to standard ones
|
||||
|
||||
### Testing
|
||||
|
||||
- Test both success and failure scenarios
|
||||
- Mock external API calls
|
||||
- Test normalization edge cases
|
||||
- Include integration tests
|
||||
|
||||
## Example: Complete Implementation
|
||||
|
||||
For a complete example, refer to the existing client implementations:
|
||||
|
||||
- **SABnzbdClient.js**: Simple REST API client
|
||||
- **QBittorrentClient.js**: Complex client with sync API and fallback
|
||||
- **TransmissionClient.js**: JSON-RPC client with session management
|
||||
- **RTorrentClient.js**: XML-RPC client with HTTP Basic Auth
|
||||
|
||||
### rTorrent Specific Notes
|
||||
|
||||
rTorrent uses XML-RPC over HTTP with the following specifics:
|
||||
|
||||
- **Endpoint**: `${url}/RPC2` (most common)
|
||||
- **Authentication**: HTTP Basic Auth (handled by reverse proxy or web server)
|
||||
- **Primary Method**: `d.multicall2` for efficient bulk torrent data retrieval
|
||||
- **Library**: Uses the `xmlrpc` package (v1.3.2)
|
||||
- **Status Mapping**: Combines `d.state`, `d.is_active`, and `d.is_hash_checking` to determine status
|
||||
- **ETA Calculation**: Computed from download speed and remaining bytes when actively downloading
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication failures**: Check credentials and URL format
|
||||
2. **API changes**: Ensure your client matches the API version
|
||||
3. **Network issues**: Implement proper timeout and retry logic
|
||||
4. **Data normalization**: Verify all required fields are populated
|
||||
|
||||
### Debugging
|
||||
|
||||
- Enable debug logging in your client
|
||||
- Check the server logs for error messages
|
||||
- Use the test connection endpoint to verify configuration
|
||||
- Test API calls manually before implementing
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing a new client:
|
||||
|
||||
1. Follow the existing code style and patterns
|
||||
2. Include comprehensive tests
|
||||
3. Update all relevant documentation
|
||||
4. Test with multiple instances if supported
|
||||
5. Consider edge cases and error scenarios
|
||||
|
||||
## Support
|
||||
|
||||
If you need help implementing a new client:
|
||||
|
||||
1. Review existing client implementations
|
||||
2. Check the architecture documentation
|
||||
3. Look at the test examples
|
||||
4. Ask questions in the project discussions
|
||||
|
||||
---
|
||||
|
||||
*This guide covers the basics of adding a new download client. For more advanced scenarios, refer to the source code and existing implementations.*
|
||||
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 139 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.2.1",
|
||||
"version": "1.7.28",
|
||||
"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": {
|
||||
@@ -13,7 +13,10 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -22,10 +25,16 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1"
|
||||
"jsdom": "^29.1.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xmlrpc": "^1.3.2",
|
||||
"yamljs": "^0.3.0"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 966 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 778 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="app">
|
||||
<!-- Login Form -->
|
||||
<div id="login-container" class="login-container" style="display: none;">
|
||||
<div id="login-container" class="login-container hidden">
|
||||
<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>
|
||||
@@ -39,21 +39,21 @@
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
<div id="login-error" class="error-message hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<div id="dashboard-container" class="dashboard-container hidden">
|
||||
<header class="app-header">
|
||||
<h1><a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="title-link">sofarr</a></h1>
|
||||
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||
<div class="header-controls">
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-btn active" data-theme="light">Light</button>
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<div id="admin-controls" class="admin-controls hidden">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
<span>Show all users</span>
|
||||
@@ -68,21 +68,129 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
||||
<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="error-message" class="error-message" 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>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</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 id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<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>
|
||||
@@ -90,7 +198,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||
<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">A–Z</option>
|
||||
<option value="title_desc">Z–A</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">
|
||||
@@ -98,11 +292,15 @@
|
||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||
<span class="history-days-label">days</span>
|
||||
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
||||
<input type="checkbox" id="ignore-available-toggle">
|
||||
<span>Hide upgrade failures</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||
<div id="no-history" class="no-history" style="display: none;">
|
||||
<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>
|
||||
@@ -112,7 +310,7 @@
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||
<p class="app-version" id="app-version"></p>
|
||||
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// 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
|
||||
@@ -10,19 +11,47 @@ 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 fs = require('fs');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
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 debugRoutes = require('./routes/debug');
|
||||
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)
|
||||
@@ -70,6 +99,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
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' }
|
||||
});
|
||||
|
||||
@@ -77,10 +107,79 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
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
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.28"
|
||||
* 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() });
|
||||
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"
|
||||
* 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) {
|
||||
@@ -90,9 +189,38 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
@@ -100,9 +228,49 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
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);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// SPA catch-all — serve index.html for any unmatched path
|
||||
app.get('*', serveIndex);
|
||||
|
||||
// Global error handler
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* Abstract base class for all download clients.
|
||||
* Defines the common interface that all download clients must implement.
|
||||
*/
|
||||
class DownloadClient {
|
||||
/**
|
||||
* @param {Object} instanceConfig - Configuration for this client instance
|
||||
* @param {string} instanceConfig.id - Unique identifier for this instance
|
||||
* @param {string} instanceConfig.name - Display name for this instance
|
||||
* @param {string} instanceConfig.url - Base URL for the client API
|
||||
* @param {string} [instanceConfig.apiKey] - API key for authentication (if applicable)
|
||||
* @param {string} [instanceConfig.username] - Username for authentication (if applicable)
|
||||
* @param {string} [instanceConfig.password] - Password for authentication (if applicable)
|
||||
*/
|
||||
constructor(instanceConfig) {
|
||||
if (this.constructor === DownloadClient) {
|
||||
throw new Error('DownloadClient is an abstract class and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.id = instanceConfig.id;
|
||||
this.name = instanceConfig.name;
|
||||
this.url = instanceConfig.url;
|
||||
this.apiKey = instanceConfig.apiKey;
|
||||
this.username = instanceConfig.username;
|
||||
this.password = instanceConfig.password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client type identifier (e.g., 'qbittorrent', 'sabnzbd', 'transmission')
|
||||
* @returns {string} The client type
|
||||
*/
|
||||
getClientType() {
|
||||
throw new Error('getClientType() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique instance ID
|
||||
* @returns {string} The instance ID
|
||||
*/
|
||||
getInstanceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to the download client
|
||||
* @returns {Promise<boolean>} True if connection is successful
|
||||
*/
|
||||
async testConnection() {
|
||||
throw new Error('testConnection() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active downloads from this client
|
||||
* @returns {Promise<Array<NormalizedDownload>>} Array of normalized download objects
|
||||
*/
|
||||
async getActiveDownloads() {
|
||||
throw new Error('getActiveDownloads() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Get client status information
|
||||
* @returns {Promise<Object|null>} Client status object or null if not supported
|
||||
*/
|
||||
async getClientStatus() {
|
||||
return null; // Default implementation - optional method
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a download object to the standard schema
|
||||
* @param {Object} download - Raw download object from client
|
||||
* @returns {NormalizedDownload} Normalized download object
|
||||
*/
|
||||
normalizeDownload(download) {
|
||||
throw new Error('normalizeDownload() must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedDownload
|
||||
* @property {string} id - Client-specific unique ID
|
||||
* @property {string} title - Download title/name
|
||||
* @property {'usenet'|'torrent'} type - Download type
|
||||
* @property {string} client - Client identifier ('sabnzbd', 'qbittorrent', 'transmission', etc.)
|
||||
* @property {string} instanceId - Instance identifier
|
||||
* @property {string} instanceName - Instance display name
|
||||
* @property {string} status - Normalized status (Downloading, Seeding, Paused, etc.)
|
||||
* @property {number} progress - Progress percentage (0-100)
|
||||
* @property {number} size - Total size in bytes
|
||||
* @property {number} downloaded - Downloaded bytes
|
||||
* @property {number} speed - Current speed in bytes/sec
|
||||
* @property {number|null} eta - Estimated time remaining in seconds, null if unknown
|
||||
* @property {string|undefined} category - Download category (optional)
|
||||
* @property {string[]|undefined} tags - Download tags (optional)
|
||||
* @property {string|undefined} savePath - Save path (optional)
|
||||
* @property {string|undefined} addedOn - Added timestamp (optional)
|
||||
* @property {number|undefined} arrQueueId - Sonarr/Radarr queue ID (optional)
|
||||
* @property {'series'|'movie'|undefined} arrType - Sonarr/Radarr type (optional)
|
||||
* @property {any|undefined} raw - Original client response (escape hatch)
|
||||
*/
|
||||
|
||||
module.exports = DownloadClient;
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users from Ombi
|
||||
* @returns {Promise<Array>} Array of user objects
|
||||
*/
|
||||
async getUsers() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OmbiClient;
|
||||
@@ -0,0 +1,366 @@
|
||||
// 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: [],
|
||||
users: [],
|
||||
movieMap: new Map(), // tmdbId -> request
|
||||
tvMap: new Map(), // tvdbId -> request
|
||||
userMap: new Map(), // id -> user
|
||||
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 and users in parallel
|
||||
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||
this.client.getMovieRequests(),
|
||||
this.client.getTvRequests(),
|
||||
this.client.getUsers()
|
||||
]);
|
||||
|
||||
// Update cache
|
||||
this.cache.movieRequests = movieRequests;
|
||||
this.cache.tvRequests = tvRequests;
|
||||
this.cache.users = users;
|
||||
this.cache.lastFetch = Date.now();
|
||||
|
||||
// Build lookup maps
|
||||
this.cache.movieMap.clear();
|
||||
this.cache.tvMap.clear();
|
||||
this.cache.userMap.clear();
|
||||
|
||||
// Build user map (id -> user)
|
||||
if (Array.isArray(users)) {
|
||||
users.forEach(user => {
|
||||
if (user && user.id) {
|
||||
this.cache.userMap.set(user.id, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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, ${users.length} users`);
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||
// Don't throw error, continue with stale cache if available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a single request using the userMap cache
|
||||
* @param {Object} req - The request object
|
||||
* @returns {Object} Hydrated request object
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequest(req) {
|
||||
if (!req) return req;
|
||||
|
||||
let result = req;
|
||||
|
||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||
|
||||
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||
|
||||
// If requestedUser is not an object or is empty/null, populate it
|
||||
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||
const hydratedUser = {
|
||||
id: cachedUser.id,
|
||||
userName: cachedUser.userName,
|
||||
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||
};
|
||||
|
||||
result = {
|
||||
...req,
|
||||
requestedUser: hydratedUser,
|
||||
RequestedUser: hydratedUser
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate childRequests (common for Ombi TV show requests)
|
||||
if (Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||
const hydratedChildren = result.childRequests.map(child => {
|
||||
if (!child) return child;
|
||||
|
||||
const childUserId = child.requestedUserId || child.RequestedUserId;
|
||||
if (childUserId && this.cache.userMap.has(childUserId)) {
|
||||
const cachedUser = this.cache.userMap.get(childUserId);
|
||||
let childUser = child.requestedUser || child.RequestedUser;
|
||||
|
||||
if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) {
|
||||
const hydratedUser = {
|
||||
id: cachedUser.id,
|
||||
userName: cachedUser.userName,
|
||||
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||
};
|
||||
|
||||
return {
|
||||
...child,
|
||||
requestedUser: hydratedUser,
|
||||
RequestedUser: hydratedUser
|
||||
};
|
||||
}
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
result = { ...result, childRequests: hydratedChildren };
|
||||
}
|
||||
|
||||
// Promote requestedDate from childRequests to top level (common for Ombi TV)
|
||||
if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||
const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate;
|
||||
if (childDate) {
|
||||
result = { ...result, requestedDate: childDate };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||
* @param {Array} requests - Array of request objects
|
||||
* @returns {Array} Array of hydrated request objects
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequests(requests) {
|
||||
if (!Array.isArray(requests)) return [];
|
||||
return requests.map(req => this._hydrateRequest(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._hydrateRequests(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._hydrateRequests(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._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||
}
|
||||
|
||||
// Try IMDB ID as fallback
|
||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||
return this._hydrateRequest(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._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||
}
|
||||
|
||||
// Try TMDB ID as fallback
|
||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||
return this._hydrateRequest(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;
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,266 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const xmlrpc = require('xmlrpc');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* rTorrent download client implementation.
|
||||
* Communicates via XML-RPC over HTTP.
|
||||
* Supports HTTP Basic Auth when username/password are configured.
|
||||
* The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc).
|
||||
*/
|
||||
class RTorrentClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
this._createClient();
|
||||
}
|
||||
|
||||
_createClient() {
|
||||
const clientOptions = { url: this.url };
|
||||
|
||||
if (this.username && this.password) {
|
||||
clientOptions.headers = {
|
||||
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`
|
||||
};
|
||||
}
|
||||
|
||||
this.client = xmlrpc.createClient(clientOptions);
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'rtorrent';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
await this._methodCall('system.client_version');
|
||||
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap xmlrpc methodCall in a Promise.
|
||||
* @param {string} method - XML-RPC method name
|
||||
* @param {Array} params - Method parameters
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
_methodCall(method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.methodCall(method, params, (error, value) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
const torrents = await this._methodCall('d.multicall2', [
|
||||
'',
|
||||
'd.hash=',
|
||||
'd.name=',
|
||||
'd.size_bytes=',
|
||||
'd.completed_bytes=',
|
||||
'd.down.rate=',
|
||||
'd.up.rate=',
|
||||
'd.state=',
|
||||
'd.is_active=',
|
||||
'd.is_hash_checking=',
|
||||
'd.directory=',
|
||||
'd.custom1='
|
||||
]);
|
||||
|
||||
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const [downRate, upRate] = await Promise.all([
|
||||
this._methodCall('throttle.global_down.rate'),
|
||||
this._methodCall('throttle.global_up.rate')
|
||||
]);
|
||||
|
||||
return {
|
||||
globalDownRate: downRate,
|
||||
globalUpRate: upRate
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
const [
|
||||
hash,
|
||||
name,
|
||||
sizeBytes,
|
||||
completedBytes,
|
||||
downRate,
|
||||
upRate,
|
||||
state,
|
||||
isActive,
|
||||
isHashChecking,
|
||||
directory,
|
||||
custom1
|
||||
] = torrent;
|
||||
|
||||
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
||||
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
||||
|
||||
// Calculate ETA when actively downloading
|
||||
let eta = null;
|
||||
if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) {
|
||||
eta = Math.round((sizeBytes - completedBytes) / downRate);
|
||||
}
|
||||
|
||||
const arrInfo = this._extractArrInfo(name);
|
||||
|
||||
return {
|
||||
id: hash,
|
||||
title: name,
|
||||
type: 'torrent',
|
||||
client: 'rtorrent',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status,
|
||||
progress,
|
||||
size: sizeBytes,
|
||||
downloaded: completedBytes,
|
||||
speed: status === 'Seeding' ? upRate : downRate,
|
||||
eta,
|
||||
category: custom1 || undefined,
|
||||
tags: custom1 ? [custom1] : [],
|
||||
savePath: directory || undefined,
|
||||
addedOn: undefined, // rtorrent does not expose added time via multicall2
|
||||
arrQueueId: arrInfo.queueId,
|
||||
arrType: arrInfo.type,
|
||||
raw: torrent
|
||||
};
|
||||
}
|
||||
|
||||
_mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) {
|
||||
if (isHashChecking === 1) {
|
||||
return 'Checking';
|
||||
}
|
||||
|
||||
if (state === 0) {
|
||||
return 'Stopped';
|
||||
}
|
||||
|
||||
if (isActive === 1) {
|
||||
return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading';
|
||||
}
|
||||
|
||||
return 'Paused';
|
||||
}
|
||||
|
||||
_extractArrInfo(filename) {
|
||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||
if (seriesMatch) {
|
||||
return { type: 'series' };
|
||||
}
|
||||
|
||||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||||
if (movieMatch) {
|
||||
return { type: 'movie' };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RTorrentClient;
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const DownloadClient = require('./DownloadClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
class TransmissionClient extends DownloadClient {
|
||||
constructor(instance) {
|
||||
super(instance);
|
||||
this.sessionId = null;
|
||||
this.rpcUrl = `${this.url}/transmission/rpc`;
|
||||
}
|
||||
|
||||
getClientType() {
|
||||
return 'transmission';
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.makeRequest('session-get');
|
||||
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(method, arguments_ = {}, config = {}) {
|
||||
const payload = {
|
||||
method,
|
||||
arguments: arguments_
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.sessionId) {
|
||||
headers['X-Transmission-Session-Id'] = this.sessionId;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.rpcUrl, payload, {
|
||||
headers,
|
||||
...config
|
||||
});
|
||||
|
||||
if (response.data.result !== 'success') {
|
||||
throw new Error(`Transmission RPC error: ${response.data.result}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Handle session ID conflict (409 Conflict)
|
||||
if (error.response && error.response.status === 409) {
|
||||
const sessionId = error.response.headers['x-transmission-session-id'];
|
||||
if (sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
logToFile(`[Transmission:${this.name}] Updated session ID`);
|
||||
return this.makeRequest(method, arguments_, config);
|
||||
}
|
||||
}
|
||||
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Get all torrents with detailed fields
|
||||
const response = await this.makeRequest('torrent-get', {
|
||||
fields: [
|
||||
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
|
||||
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
|
||||
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
|
||||
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
|
||||
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
|
||||
]
|
||||
});
|
||||
|
||||
const torrents = response.data.arguments.torrents || [];
|
||||
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getClientStatus() {
|
||||
try {
|
||||
const response = await this.makeRequest('session-get');
|
||||
const sessionStats = await this.makeRequest('session-stats');
|
||||
|
||||
return {
|
||||
session: response.data.arguments,
|
||||
stats: sessionStats.data.arguments
|
||||
};
|
||||
} catch (error) {
|
||||
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(torrent) {
|
||||
// Map Transmission status codes to normalized status
|
||||
const statusMap = {
|
||||
0: 'Stopped', // TORRENT_STOPPED
|
||||
1: 'Queued', // TORRENT_CHECK_WAIT
|
||||
2: 'Checking', // TORRENT_CHECK
|
||||
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
|
||||
4: 'Downloading', // TORRENT_DOWNLOAD
|
||||
5: 'Queued', // TORRENT_SEED_WAIT
|
||||
6: 'Seeding', // TORRENT_SEED
|
||||
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
|
||||
};
|
||||
|
||||
const status = statusMap[torrent.status] || 'Unknown';
|
||||
|
||||
// Calculate progress and sizes
|
||||
const progress = torrent.percentDone * 100;
|
||||
const size = torrent.totalSize;
|
||||
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
|
||||
|
||||
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
|
||||
let eta = null;
|
||||
if (torrent.eta >= 0) {
|
||||
eta = torrent.eta;
|
||||
}
|
||||
|
||||
// Extract category/labels
|
||||
const labels = torrent.labels || [];
|
||||
const category = labels.length > 0 ? labels[0] : undefined;
|
||||
|
||||
// Try to extract Sonarr/Radarr info from name
|
||||
const arrInfo = this.extractArrInfo(torrent.name);
|
||||
|
||||
return {
|
||||
id: torrent.hashString,
|
||||
title: torrent.name,
|
||||
type: 'torrent',
|
||||
client: 'transmission',
|
||||
instanceId: this.id,
|
||||
instanceName: this.name,
|
||||
status: status,
|
||||
progress: Math.round(progress),
|
||||
size: size,
|
||||
downloaded: downloaded,
|
||||
speed: torrent.rateDownload,
|
||||
eta: eta,
|
||||
category: category,
|
||||
tags: labels,
|
||||
savePath: torrent.downloadDir || undefined,
|
||||
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
|
||||
arrQueueId: arrInfo.queueId,
|
||||
arrType: arrInfo.type,
|
||||
raw: torrent
|
||||
};
|
||||
}
|
||||
|
||||
extractArrInfo(filename) {
|
||||
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
|
||||
|
||||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||
if (seriesMatch) {
|
||||
return { type: 'series' };
|
||||
}
|
||||
|
||||
// Look for movie year patterns like "Movie Title (2023)"
|
||||
const movieMatch = filename.match(/\((\d{4})\)/);
|
||||
if (movieMatch && !seriesMatch) {
|
||||
return { type: 'movie' };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TransmissionClient;
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
@@ -8,8 +8,13 @@ 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 logCapture = require('./utils/logCapture');
|
||||
logCapture.init();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -77,16 +82,9 @@ console.error = function(...args) {
|
||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||
};
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
const { createApp } = require('./app');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup environment validation
|
||||
@@ -108,172 +106,10 @@ if (process.env.EMBY_URL) {
|
||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const app = createApp();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
: process.env.TRUST_PROXY;
|
||||
app.set('trust proxy', trustValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helmet v7 — security response headers
|
||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
// Generate a fresh nonce for every request
|
||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 year
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader(
|
||||
'Permissions-Policy',
|
||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// General API rate limiter — applies to all /api/* routes
|
||||
// More specific limiters (e.g. login) apply on top of this.
|
||||
// ---------------------------------------------------------------------------
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body parsing & cookies
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health / readiness endpoints (no auth, no rate-limit)
|
||||
// Used by Docker HEALTHCHECK and orchestrators.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
app.get('/ready', (req, res) => {
|
||||
// Confirm critical config is present
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
res.json({ status: 'ready' });
|
||||
} else {
|
||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files — served before API routes
|
||||
// index.html is served manually so we can inject the CSP nonce
|
||||
// ---------------------------------------------------------------------------
|
||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||
|
||||
// Serve all static assets (js, css, images, icons) except index.html.
|
||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||
app.use(express.static(PUBLIC_DIR, {
|
||||
index: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve index.html with CSP nonce injected into <script> tags
|
||||
function serveIndex(req, res) {
|
||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||
if (err) return res.status(500).send('Internal Server Error');
|
||||
const nonce = res.locals.cspNonce;
|
||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||
// the old nonce which no longer matches the per-request CSP header).
|
||||
const patched = html
|
||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(patched);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
||||
// CSRF protection applies to all state-changing /api/* requests except
|
||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
app.use('/api', verifyCsrf);
|
||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// 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' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS / HTTPS support
|
||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const ipaddr = require('ipaddr.js');
|
||||
|
||||
function getEmbyUrl() {
|
||||
return process.env.EMBY_URL;
|
||||
}
|
||||
|
||||
function isIpAllowed(clientIp, allowedSubnetsStr) {
|
||||
if (!allowedSubnetsStr) return true;
|
||||
try {
|
||||
const clientIpParsed = ipaddr.parse(clientIp);
|
||||
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const subnet of subnets) {
|
||||
let rangeStr = subnet;
|
||||
let bits = null;
|
||||
if (subnet.includes('/')) {
|
||||
const parts = subnet.split('/');
|
||||
rangeStr = parts[0];
|
||||
bits = parseInt(parts[1], 10);
|
||||
}
|
||||
|
||||
const rangeIpParsed = ipaddr.parse(rangeStr);
|
||||
|
||||
if (bits === null) {
|
||||
// Exact IP match
|
||||
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
// Handle IPv4 mapped IPv6 address case
|
||||
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match with subnet bits
|
||||
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
|
||||
if (clientIpParsed.match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
// Handle IPv4 mapped IPv6 address case matching IPv4 range
|
||||
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function logStreamAuth(req, res, next) {
|
||||
// 1. Subnet IP Filtering (First Priority)
|
||||
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
|
||||
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
|
||||
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
|
||||
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
|
||||
}
|
||||
|
||||
// 2. Webhook Secret Bypass (High Priority)
|
||||
const secretHeader = req.headers['x-webhook-secret'];
|
||||
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
|
||||
if (configuredSecret && secretHeader === configuredSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 3. Session Cookie
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (rawCookie && rawCookie !== false) {
|
||||
try {
|
||||
const u = JSON.parse(rawCookie);
|
||||
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
|
||||
req.user = u;
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, fallback to basic auth
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Basic Authentication Fallback
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||
try {
|
||||
const credentialsBase64 = authHeader.substring(6);
|
||||
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
|
||||
const colonIdx = credentialsStr.indexOf(':');
|
||||
|
||||
if (colonIdx !== -1) {
|
||||
const username = credentialsStr.substring(0, colonIdx).trim();
|
||||
const password = credentialsStr.substring(colonIdx + 1);
|
||||
|
||||
if (username && password) {
|
||||
const embyUrl = getEmbyUrl();
|
||||
if (!embyUrl) {
|
||||
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Authentication service unavailable' });
|
||||
}
|
||||
|
||||
// Authenticate with Emby using stable DeviceId derived from username
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
|
||||
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
|
||||
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
const userId = authData.User.Id || authData.User.id;
|
||||
|
||||
// Fetch detailed profile to verify administrator status
|
||||
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||
|
||||
if (isAdmin) {
|
||||
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
|
||||
req.user = { id: user.Id, name: user.Name, isAdmin: true };
|
||||
return next();
|
||||
} else {
|
||||
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[logStreamAuth] Emby authentication error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Unauthorized / Access Denied
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
module.exports = logStreamAuth;
|
||||
@@ -1,3 +1,4 @@
|
||||
// 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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* CSRF protection using the double-submit cookie pattern.
|
||||
*
|
||||
@@ -25,13 +26,14 @@ function verifyCsrf(req, res, next) {
|
||||
return res.status(403).json({ error: 'CSRF token missing' });
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (cookieToken.length !== headerToken.length) {
|
||||
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' });
|
||||
}
|
||||
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
@@ -23,7 +24,159 @@ const loginLimiter = rateLimit({
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
// Authenticate user with Emby
|
||||
/**
|
||||
* @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) => {
|
||||
try {
|
||||
const { username, password, rememberMe } = req.body;
|
||||
@@ -128,7 +281,80 @@ function parseSessionCookie(req) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current authenticated user
|
||||
/**
|
||||
* @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);
|
||||
* }
|
||||
*/
|
||||
router.get('/me', (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
if (!user) return res.json({ authenticated: false });
|
||||
@@ -138,8 +364,57 @@ router.get('/me', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||
// (e.g. after a page reload where the JS variable was lost)
|
||||
/**
|
||||
* @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, {
|
||||
@@ -151,7 +426,84 @@ router.get('/csrf', (req, res) => {
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
// Logout
|
||||
/**
|
||||
* @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) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||
const {
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
} = require('../utils/logCapture');
|
||||
|
||||
// Public status check (no auth, no 403 block, returns standard config state)
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||
});
|
||||
|
||||
// Global toggle check
|
||||
router.use((req, res, next) => {
|
||||
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Enforce subnet and authentication validations on all debug routes
|
||||
router.use(logStreamAuth);
|
||||
|
||||
/**
|
||||
* GET /api/debug/server-logs
|
||||
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||
*/
|
||||
router.get('/server-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical server logs buffer first
|
||||
for (const line of logBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('server-log', sendLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('server-log', sendLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/client-logs
|
||||
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||
*/
|
||||
router.get('/client-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical client logs buffer first
|
||||
for (const line of clientLogBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendClientLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('client-log', sendClientLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('client-log', sendClientLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/debug/client-logs
|
||||
* Receives batches of frontend console logs to store in buffer and emit.
|
||||
*/
|
||||
router.post('/client-logs', (req, res) => {
|
||||
const logs = req.body;
|
||||
if (!Array.isArray(logs)) {
|
||||
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||
}
|
||||
|
||||
try {
|
||||
ingestClientLogs(logs);
|
||||
return res.status(200).json({ success: true, count: logs.length });
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,12 +1,30 @@
|
||||
// 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);
|
||||
|
||||
// Get active sessions
|
||||
// GET /api/emby/sessions - list active Emby sessions
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
@@ -18,19 +36,24 @@ router.get('/sessions', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
/**
|
||||
* @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`, {
|
||||
@@ -42,7 +65,79 @@ router.get('/users', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user by session ID
|
||||
/**
|
||||
* @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
|
||||
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 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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
|
||||
router.get('/session/:sessionId/user', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
|
||||
@@ -1,162 +1,217 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const TagMatcher = require('../services/TagMatcher');
|
||||
const DownloadAssembler = require('../services/DownloadAssembler');
|
||||
|
||||
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||
// from a shared location. For now they are inlined here to keep dashboard.js
|
||||
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
|
||||
// into server/utils/dashboardHelpers.js in a later refactor.
|
||||
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
if (tagLower === username) return true;
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const embyUrl = process.env.EMBY_URL;
|
||||
const embyKey = process.env.EMBY_API_KEY;
|
||||
if (!embyUrl || !embyKey) return new Map();
|
||||
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
|
||||
const users = res.data || [];
|
||||
const map = new Map();
|
||||
for (const u of users) {
|
||||
if (!u.Name) continue;
|
||||
const lower = u.Name.toLowerCase();
|
||||
map.set(lower, u.Name);
|
||||
map.set(sanitizeTagLabel(lower), u.Name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[History] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser };
|
||||
});
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr history record.
|
||||
function extractEpisode(record) {
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all history records
|
||||
// that share the same source title. Returns sorted, deduplicated array.
|
||||
function gatherEpisodes(titleLower, records) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of records) {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/recent
|
||||
* Deduplicate history items so that for each unique content item (episode or
|
||||
* movie) only the most-recent record is shown, with the following rules:
|
||||
*
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||
* (default 7, overridable via env or ?days= query param).
|
||||
* - 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.
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* user: string,
|
||||
* isAdmin: boolean,
|
||||
* days: number,
|
||||
* history: HistoryItem[]
|
||||
* }
|
||||
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
|
||||
* Records without a contentId fall through unchanged (no deduplication possible).
|
||||
*
|
||||
* HistoryItem shape:
|
||||
* {
|
||||
* type: 'series'|'movie',
|
||||
* outcome: 'imported'|'failed',
|
||||
* title: string, // sourceTitle from arr record
|
||||
* seriesName?: string, // series.title (Sonarr)
|
||||
* movieName?: string, // movie.title (Radarr)
|
||||
* coverArt: string|null,
|
||||
* completedAt: string, // ISO date string from arr record
|
||||
* quality: string|null,
|
||||
* instanceName: string, // arr instance name
|
||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||
* allTags: string[],
|
||||
* matchedUserTag: string|null,
|
||||
* // admin-only:
|
||||
* arrRecordId?: number,
|
||||
* failureMessage?: string,
|
||||
* }
|
||||
* @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 {
|
||||
@@ -175,10 +230,13 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
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 ? getEmbyUsers() : Promise.resolve(new Map())
|
||||
showAll ? TagMatcher.getEmbyUsers() : Promise.resolve(new Map())
|
||||
]);
|
||||
|
||||
// Build tag maps from the cached poll data where available,
|
||||
@@ -199,8 +257,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
const series = record.series;
|
||||
if (!series) continue;
|
||||
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
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;
|
||||
@@ -215,19 +273,23 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
outcome,
|
||||
title: sourceTitle,
|
||||
seriesName: series.title,
|
||||
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||
coverArt: getCoverArt(series),
|
||||
episodes: DownloadAssembler.gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getSonarrLink(series),
|
||||
arrLink: DownloadAssembler.getSonarrLink(series),
|
||||
ombiLink: DownloadAssembler.getOmbiDetailsLink(series, 'series', ombiBaseUrl),
|
||||
ombiTooltip: 'View in Ombi',
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
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;
|
||||
}
|
||||
@@ -248,8 +310,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
const movie = record.movie;
|
||||
if (!movie) continue;
|
||||
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
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;
|
||||
@@ -263,18 +325,22 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
outcome,
|
||||
title: record.sourceTitle || record.title || movie.title,
|
||||
movieName: movie.title,
|
||||
coverArt: getCoverArt(movie),
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
completedAt: record.date,
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getRadarrLink(movie),
|
||||
arrLink: DownloadAssembler.getRadarrLink(movie),
|
||||
ombiLink: DownloadAssembler.getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
|
||||
ombiTooltip: 'View in Ombi',
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
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;
|
||||
}
|
||||
@@ -286,16 +352,24 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||
// 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);
|
||||
|
||||
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||
// 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: historyItems
|
||||
history: dedupedItems
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[History] Error:', err.message);
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
// 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, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = 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' }))
|
||||
];
|
||||
|
||||
// Admin only: add Sonarr/Radarr lookup links
|
||||
if (isAdmin) {
|
||||
await decorateRequestsWithArrLinks(allRequests, isAdmin);
|
||||
}
|
||||
|
||||
// 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 webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
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 = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
|
||||
// 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 webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
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 = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||
|
||||
// Simulate a test webhook event
|
||||
const axios = require('axios');
|
||||
try {
|
||||
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}`);
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
let useHttps = false;
|
||||
if (tlsEnabled) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const certsDir = path.join(__dirname, '../../certs');
|
||||
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
|
||||
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
|
||||
try {
|
||||
fs.readFileSync(tlsCertPath);
|
||||
fs.readFileSync(tlsKeyPath);
|
||||
useHttps = true;
|
||||
} catch {
|
||||
useHttps = false;
|
||||
}
|
||||
}
|
||||
|
||||
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||
|
||||
const https = require('https');
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
await axios.post(localUrl, {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 0,
|
||||
requestedUser: 'test',
|
||||
title: 'Test Request',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending'
|
||||
}, {
|
||||
headers: {
|
||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
httpsAgent: useHttps ? agent : undefined
|
||||
});
|
||||
|
||||
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,16 +1,55 @@
|
||||
// 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, getSofarrWebhookBaseUrl } = 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);
|
||||
|
||||
// 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(`${process.env.RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -18,11 +57,39 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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(`${process.env.RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
|
||||
const response = await axios.get(`${instance.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
@@ -33,9 +100,13 @@ router.get('/history', async (req, res) => {
|
||||
|
||||
// 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(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -45,9 +116,13 @@ router.get('/movies/:id', async (req, res) => {
|
||||
|
||||
// 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(`${process.env.RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -55,4 +130,224 @@ router.get('/movies', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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 webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
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 = `${webhookBaseUrl}/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) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,18 +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 { 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 current queue
|
||||
// GET /api/sabnzbd/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(`${process.env.SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${instance.url}/api`, {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
apikey: instance.apiKey,
|
||||
output: 'json'
|
||||
}
|
||||
});
|
||||
@@ -22,13 +60,41 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
/**
|
||||
* @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
|
||||
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(`${process.env.SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${instance.url}/api`, {
|
||||
params: {
|
||||
mode: 'history',
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
apikey: instance.apiKey,
|
||||
output: 'json',
|
||||
limit: req.query.limit || 50
|
||||
}
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
// 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, getSofarrWebhookBaseUrl } = 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);
|
||||
|
||||
// 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(`${process.env.SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -18,11 +57,39 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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(`${process.env.SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
|
||||
const response = await axios.get(`${instance.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
@@ -33,9 +100,13 @@ router.get('/history', async (req, res) => {
|
||||
|
||||
// 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(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -45,9 +116,13 @@ router.get('/series/:id', async (req, res) => {
|
||||
|
||||
// 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(`${process.env.SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
@@ -55,4 +130,224 @@ router.get('/series', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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 webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
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 = `${webhookBaseUrl}/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) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,848 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
||||
const cache = require('../utils/cache');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/webhook/config:
|
||||
* get:
|
||||
* tags: [Webhook]
|
||||
* summary: Get webhook configuration status
|
||||
* description: |
|
||||
* Returns whether the required webhook configuration (SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
|
||||
* is properly configured. Used by the webhooks panel to determine if webhooks can be enabled.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Webhook configuration status
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* valid:
|
||||
* type: boolean
|
||||
* description: true if both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured
|
||||
* example: true
|
||||
* missing:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: List of missing configuration items
|
||||
* example: []
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get('/config', requireAuth, (req, res) => {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
const missing = [];
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
missing.push('SOFARR_BASE_URL');
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
missing.push('SOFARR_WEBHOOK_SECRET');
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: missing.length === 0,
|
||||
missing
|
||||
});
|
||||
});
|
||||
|
||||
// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter.
|
||||
// Sonarr/Radarr send at most one event per action; 60/min per IP is generous.
|
||||
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
|
||||
const webhookLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many webhook requests' }
|
||||
});
|
||||
|
||||
// Valid *arr eventType strings — used for strict input validation.
|
||||
const VALID_EVENT_TYPES = new Set([
|
||||
'Test',
|
||||
'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired',
|
||||
'DownloadFolderImported', 'ImportFailed',
|
||||
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||
// Ombi notification types
|
||||
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||
]);
|
||||
|
||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||
// *arr sends a `date` field on every event; we use it as the replay key component.
|
||||
// TTL = 5 minutes; an event replayed after that window is considered fresh.
|
||||
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
||||
const recentEvents = new Map();
|
||||
|
||||
function pruneReplayCache() {
|
||||
const cutoff = Date.now() - REPLAY_WINDOW_MS;
|
||||
for (const [key, ts] of recentEvents) {
|
||||
if (ts < cutoff) recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune the replay cache once per minute
|
||||
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||
|
||||
function isReplay(eventType, instanceName, eventDate) {
|
||||
if (!eventDate) return false;
|
||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
||||
if (recentEvents.has(key)) return true;
|
||||
recentEvents.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
|
||||
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||
|
||||
// Event classification — determines which cache keys to refresh
|
||||
const QUEUE_EVENTS = new Set([
|
||||
'Grab',
|
||||
'Download',
|
||||
'DownloadFailed',
|
||||
'ManualInteractionRequired'
|
||||
]);
|
||||
|
||||
const HISTORY_EVENTS = new Set([
|
||||
'DownloadFolderImported',
|
||||
'ImportFailed',
|
||||
'EpisodeFileRenamed',
|
||||
'MovieFileRenamed',
|
||||
'EpisodeFileRenamedBySeries'
|
||||
]);
|
||||
|
||||
// Ombi event types — all Ombi events refresh the requests cache
|
||||
const OMBI_EVENTS = new Set([
|
||||
'NewRequest',
|
||||
'RequestAvailable',
|
||||
'RequestApproved',
|
||||
'RequestDeclined',
|
||||
'RequestPending',
|
||||
'RequestProcessing'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} True if secret is valid, false otherwise
|
||||
*/
|
||||
function validateWebhookSecret(req) {
|
||||
const expectedSecret = getWebhookSecret();
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||
|
||||
if (!expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!providedSecret) {
|
||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (providedSecret !== expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: Invalid webhook secret provided');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a webhook event by refreshing the affected cache and broadcasting SSE.
|
||||
* This is a fire-and-forget background task — callers must respond to the webhook
|
||||
* sender before awaiting this function.
|
||||
*
|
||||
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
||||
*
|
||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||
* @param {string} eventType - the eventType from the webhook payload
|
||||
*/
|
||||
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||
|
||||
if (!affectsQueue && !affectsHistory && !affectsOmbi) {
|
||||
logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`);
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`);
|
||||
|
||||
// Ensure retrievers are initialized (idempotent)
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
if (serviceType === 'sonarr') {
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
|
||||
if (affectsQueue) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const sonarrQueues = queuesByType.sonarr || [];
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||
}
|
||||
|
||||
if (affectsHistory) {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
||||
const sonarrHistories = historyByType.sonarr || [];
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`);
|
||||
}
|
||||
} else if (serviceType === 'radarr') {
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
if (affectsQueue) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const radarrQueues = queuesByType.radarr || [];
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||
}
|
||||
|
||||
if (affectsHistory) {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
||||
const radarrHistories = historyByType.radarr || [];
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
|
||||
}
|
||||
} else if (serviceType === 'ombi') {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
if (affectsOmbi) {
|
||||
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
||||
await new Promise(r => setTimeout(r, initialDelay));
|
||||
|
||||
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
||||
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
||||
|
||||
let ombiRequests = { movie: [], tv: [] };
|
||||
let foundAndValid = false;
|
||||
const maxRetries = 3;
|
||||
const retryDelayMs = 1500;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 1) {
|
||||
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
||||
await new Promise(r => setTimeout(r, retryDelayMs));
|
||||
}
|
||||
|
||||
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
|
||||
if (!requestId) {
|
||||
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
||||
foundAndValid = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Search in movie or tv lists
|
||||
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
||||
// Also check both if mediaType not specified
|
||||
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||
|
||||
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||
if (targetReq) {
|
||||
const user = extractRequestedUser(targetReq);
|
||||
if (user) {
|
||||
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
||||
foundAndValid = true;
|
||||
break;
|
||||
} else {
|
||||
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
||||
}
|
||||
} else {
|
||||
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundAndValid && requestId) {
|
||||
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
||||
// Try to log the raw target request if we found one
|
||||
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||
if (targetReq) {
|
||||
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
||||
} else {
|
||||
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
||||
}
|
||||
}
|
||||
|
||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
|
||||
// pollAllServices() refreshes all data, updates every cache key, and then
|
||||
// iterates pollSubscribers to push fresh payloads to every open SSE connection.
|
||||
// If a poll is already in progress this call is a no-op, but the cache keys
|
||||
// above were already updated so the next broadcast (or dashboard request)
|
||||
// will see fresh data.
|
||||
logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()');
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize the incoming webhook payload.
|
||||
* Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }.
|
||||
*/
|
||||
function validatePayload(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { valid: false, reason: 'Payload must be a JSON object' };
|
||||
}
|
||||
const { eventType, instanceName } = body;
|
||||
if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) {
|
||||
return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' };
|
||||
}
|
||||
if (!VALID_EVENT_TYPES.has(eventType)) {
|
||||
return { valid: false, reason: `Unknown eventType: ${eventType}` };
|
||||
}
|
||||
if (instanceName !== undefined && typeof instanceName !== 'string') {
|
||||
return { valid: false, reason: 'instanceName must be a string if provided' };
|
||||
}
|
||||
const eventDate = body.date || null;
|
||||
return { valid: true, eventType, instanceName: instanceName || null, eventDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/webhook/sonarr:
|
||||
* post:
|
||||
* tags: [Webhook]
|
||||
* summary: Sonarr webhook receiver
|
||||
* description: |
|
||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
|
||||
* Refreshes `poll:sonarr-queue` cache
|
||||
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, EpisodeFileRenamed, etc.):
|
||||
* Refreshes `poll:sonarr-history` cache
|
||||
* - Informational events (Test, Rename, Health, etc.):
|
||||
* Logged but no cache refresh
|
||||
*
|
||||
* **Processing Flow:**
|
||||
* 1. Validate secret → 401 if invalid
|
||||
* 2. Validate payload → 400 if invalid
|
||||
* 3. Check replay cache → 200 with duplicate=true if replay
|
||||
* 4. Update webhook metrics (enables smart polling skip)
|
||||
* 5. Return 200 immediately (don't wait for background processing)
|
||||
* 6. Background: fetch fresh data from Sonarr, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Sonarr webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/sonarr`
|
||||
* - Method: POST
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/WebhookPayload'
|
||||
* example:
|
||||
* eventType: "Grab"
|
||||
* instanceName: "Main Sonarr"
|
||||
* date: "2026-05-21T10:00:00.000Z"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Event received and accepted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* received:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* duplicate:
|
||||
* type: boolean
|
||||
* description: True if this event was already processed (replay protection)
|
||||
* example: false
|
||||
* examples:
|
||||
* newEvent:
|
||||
* received: true
|
||||
* duplicate: false
|
||||
* duplicateEvent:
|
||||
* received: true
|
||||
* duplicate: true
|
||||
* '401':
|
||||
* description: Invalid or missing webhook secret
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Unauthorized"
|
||||
* '400':
|
||||
* description: Invalid payload or unknown event type
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* examples:
|
||||
* invalidPayload:
|
||||
* error: "Payload must be a JSON object"
|
||||
* unknownEventType:
|
||||
* error: "Unknown eventType: InvalidEvent"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL (from Sonarr)
|
||||
* source: |
|
||||
* curl -X POST http://sofarr:3001/api/webhook/sonarr \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
||||
* -d '{"eventType":"Grab","instanceName":"Main Sonarr","date":"2026-05-21T10:00:00.000Z"}'
|
||||
*/
|
||||
router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
if (inst) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`);
|
||||
}
|
||||
|
||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||
processWebhookEvent('sonarr', eventType).catch(err => {
|
||||
logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Webhook] Sonarr error: ${error.message}`);
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/webhook/radarr:
|
||||
* post:
|
||||
* tags: [Webhook]
|
||||
* summary: Radarr webhook receiver
|
||||
* description: |
|
||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
|
||||
* Refreshes `poll:radarr-queue` cache
|
||||
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, MovieFileRenamed, etc.):
|
||||
* Refreshes `poll:radarr-history` cache
|
||||
* - Informational events (Test, Rename, Health, etc.):
|
||||
* Logged but no cache refresh
|
||||
*
|
||||
* **Processing Flow:**
|
||||
* 1. Validate secret → 401 if invalid
|
||||
* 2. Validate payload → 400 if invalid
|
||||
* 3. Check replay cache → 200 with duplicate=true if replay
|
||||
* 4. Update webhook metrics (enables smart polling skip)
|
||||
* 5. Return 200 immediately (don't wait for background processing)
|
||||
* 6. Background: fetch fresh data from Radarr, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Radarr webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/radarr`
|
||||
* - Method: POST
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/WebhookPayload'
|
||||
* example:
|
||||
* eventType: "Grab"
|
||||
* instanceName: "Main Radarr"
|
||||
* date: "2026-05-21T10:00:00.000Z"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Event received and accepted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* received:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* duplicate:
|
||||
* type: boolean
|
||||
* description: True if this event was already processed (replay protection)
|
||||
* example: false
|
||||
* examples:
|
||||
* newEvent:
|
||||
* received: true
|
||||
* duplicate: false
|
||||
* duplicateEvent:
|
||||
* received: true
|
||||
* duplicate: true
|
||||
* '401':
|
||||
* description: Invalid or missing webhook secret
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Unauthorized"
|
||||
* '400':
|
||||
* description: Invalid payload or unknown event type
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* examples:
|
||||
* invalidPayload:
|
||||
* error: "Payload must be a JSON object"
|
||||
* unknownEventType:
|
||||
* error: "Unknown eventType: InvalidEvent"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL (from Radarr)
|
||||
* source: |
|
||||
* curl -X POST http://sofarr:3001/api/webhook/radarr \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
||||
* -d '{"eventType":"Grab","instanceName":"Main Radarr","date":"2026-05-21T10:00:00.000Z"}'
|
||||
*/
|
||||
router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
if (inst) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`);
|
||||
}
|
||||
|
||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||
processWebhookEvent('radarr', eventType).catch(err => {
|
||||
logToFile(`[Webhook] Radarr background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Webhook] Radarr error: ${error.message}`);
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/webhook/ombi:
|
||||
* post:
|
||||
* tags: [Webhook]
|
||||
* summary: Ombi webhook receiver
|
||||
* description: |
|
||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with notificationType, requestId)
|
||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - OMBI_EVENTS (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing):
|
||||
* Refreshes `poll:ombi-requests` cache
|
||||
*
|
||||
* **Processing Flow:**
|
||||
* 1. Validate secret → 401 if invalid
|
||||
* 2. Validate payload → 400 if invalid
|
||||
* 3. Check replay cache → 200 with duplicate=true if replay
|
||||
* 4. Update webhook metrics (enables smart polling skip)
|
||||
* 5. Return 200 immediately (don't wait for background processing)
|
||||
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Ombi webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
|
||||
* - Method: POST
|
||||
* - Application Token: OMBI_API_KEY
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* notificationType:
|
||||
* type: string
|
||||
* example: "RequestAvailable"
|
||||
* requestId:
|
||||
* type: integer
|
||||
* example: 123
|
||||
* requestedUser:
|
||||
* type: string
|
||||
* example: "username"
|
||||
* title:
|
||||
* type: string
|
||||
* example: "Movie Title"
|
||||
* type:
|
||||
* type: string
|
||||
* example: "Movie"
|
||||
* requestStatus:
|
||||
* type: string
|
||||
* example: "Available"
|
||||
* example:
|
||||
* notificationType: "RequestAvailable"
|
||||
* requestId: 123
|
||||
* requestedUser: "username"
|
||||
* title: "Movie Title"
|
||||
* type: "Movie"
|
||||
* requestStatus: "Available"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Event received and accepted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* received:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* duplicate:
|
||||
* type: boolean
|
||||
* description: True if this event was already processed (replay protection)
|
||||
* example: false
|
||||
* examples:
|
||||
* newEvent:
|
||||
* received: true
|
||||
* duplicate: false
|
||||
* duplicateEvent:
|
||||
* received: true
|
||||
* duplicate: true
|
||||
* '401':
|
||||
* description: Invalid or missing webhook secret
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Unauthorized"
|
||||
* '400':
|
||||
* description: Invalid payload or unknown event type
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* examples:
|
||||
* invalidPayload:
|
||||
* error: "Payload must be a JSON object"
|
||||
* unknownEventType:
|
||||
* error: "Unknown notificationType: InvalidEvent"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL (from Ombi)
|
||||
* source: |
|
||||
* curl -X POST http://sofarr:3001/api/webhook/ombi \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
||||
* -d '{"notificationType":"RequestAvailable","requestId":123,"requestedUser":"username","title":"Movie Title","type":"Movie","requestStatus":"Available"}'
|
||||
*/
|
||||
router.post('/ombi', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
|
||||
const notificationType = req.body.notificationType || req.body.NotificationType;
|
||||
const requestId = req.body.requestId || req.body.RequestId;
|
||||
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
||||
|
||||
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
||||
|
||||
// Extract username from requestedUser (handles both object and string formats)
|
||||
const username = extractRequestedUser(req.body);
|
||||
|
||||
if (!eventType || !OMBI_EVENTS.has(eventType)) {
|
||||
logToFile(`[Webhook] Ombi payload rejected: invalid or missing notificationType`);
|
||||
return res.status(400).json({ error: 'Invalid or missing notificationType' });
|
||||
}
|
||||
|
||||
// Use applicationUrl as instance identifier for replay protection
|
||||
const instanceName = applicationUrl || 'ombi';
|
||||
// Use requestId + eventType + current time as replay key
|
||||
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
||||
|
||||
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
||||
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile(`[Webhook] Ombi event received - Type: ${eventType}, RequestId: ${requestId}, User: ${username}`);
|
||||
logToFile(`[Webhook] Ombi payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Update webhook metrics for polling optimization
|
||||
const ombiInstances = getOmbiInstances();
|
||||
const inst = ombiInstances[0]; // Use first Ombi instance
|
||||
if (inst) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
logToFile(`[Webhook] Updated metrics for Ombi instance: ${inst.name} (${inst.url})`);
|
||||
}
|
||||
|
||||
// Background cache refresh + SSE broadcast (fire-and-forget)
|
||||
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Webhook] Ombi error: ${error.message}`);
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Helper function to extract poster/cover art URL from a movie or series object
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
// Fallback to fanart if no poster
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
// Extract import issues from a Sonarr/Radarr queue record
|
||||
function getImportIssues(queueRecord) {
|
||||
if (!queueRecord) return null;
|
||||
const state = queueRecord.trackedDownloadState;
|
||||
const status = queueRecord.trackedDownloadStatus;
|
||||
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||
const messages = [];
|
||||
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||
for (const sm of queueRecord.statusMessages) {
|
||||
if (sm.messages && sm.messages.length > 0) {
|
||||
messages.push(...sm.messages);
|
||||
} else if (sm.title) {
|
||||
messages.push(sm.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (queueRecord.errorMessage) {
|
||||
messages.push(queueRecord.errorMessage);
|
||||
}
|
||||
if (messages.length === 0) return null;
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Helper to build Sonarr web UI link for a series
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
// Helper to build Radarr web UI link for a movie
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Helper to build Ombi details link using TMDB ID from *arr media object
|
||||
// Movies: {ombiBaseUrl}/details/movie/{tmdbId}
|
||||
// TV: {ombiBaseUrl}/details/tv/{tmdbId}
|
||||
function getOmbiDetailsLink(mediaObj, type, ombiBaseUrl) {
|
||||
if (!ombiBaseUrl || !mediaObj) return null;
|
||||
const tmdbId = mediaObj.tmdbId;
|
||||
if (!tmdbId) return null;
|
||||
if (type === 'series') {
|
||||
return `${ombiBaseUrl}/details/tv/${tmdbId}`;
|
||||
} else if (type === 'movie') {
|
||||
return `${ombiBaseUrl}/details/movie/${tmdbId}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if a download can be blocklisted by the current user
|
||||
// Admins: always true (they have arrQueueId)
|
||||
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
||||
function canBlocklist(download, isAdmin) {
|
||||
if (isAdmin) return true;
|
||||
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
|
||||
const addedOn = new Date(download.addedOn).getTime();
|
||||
const isOldEnough = addedOn < oneHourAgo;
|
||||
const availability = parseFloat(download.availability);
|
||||
const isLowAvailability = availability < 100;
|
||||
return isOldEnough && isLowAvailability;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr queue/history record.
|
||||
// Returns { season, episode, title } or null if data is missing.
|
||||
function extractEpisode(record) {
|
||||
if (!record) return null;
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all queue/history records
|
||||
// that share the same title string. Returns sorted array of { season, episode, title }.
|
||||
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of sonarrRecords) {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCoverArt,
|
||||
getImportIssues,
|
||||
getSonarrLink,
|
||||
getRadarrLink,
|
||||
getOmbiDetailsLink,
|
||||
canBlocklist,
|
||||
extractEpisode,
|
||||
gatherEpisodes
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* DownloadBuilder - Aggregates and matches download data from multiple sources.
|
||||
* This service processes cached data from SABnzbd, qBittorrent, Sonarr, and Radarr to build
|
||||
* a unified view of downloads for each user, matching downloads to media metadata via tags.
|
||||
*/
|
||||
|
||||
const DownloadMatcher = require('./DownloadMatcher');
|
||||
|
||||
/**
|
||||
* Builds a unified list of downloads for a user from multiple download clients.
|
||||
* Matches SABnzbd and qBittorrent downloads to Sonarr/Radarr activity using tags.
|
||||
* @param {Object} cacheSnapshot - Cached data from all services
|
||||
* @param {Object} options - User context and metadata maps
|
||||
* @param {string} options.username - Lowercase username for tag matching
|
||||
* @param {string} options.usernameSanitized - Original username
|
||||
* @param {boolean} options.isAdmin - Whether user is admin
|
||||
* @param {boolean} options.showAll - Whether to show all users' downloads
|
||||
* @param {Map} options.seriesMap - Map of seriesId to series object
|
||||
* @param {Map} options.moviesMap - Map of movieId to movie object
|
||||
* @param {Map} options.sonarrTagMap - Map of Sonarr tag IDs to labels
|
||||
* @param {Map} options.radarrTagMap - Map of Radarr tag IDs to labels
|
||||
* @param {Map} options.embyUserMap - Map of Emby users for admin view
|
||||
* @param {OmbiRetriever} options.ombiRetriever - Ombi data retriever instance (optional)
|
||||
* @param {string} options.ombiBaseUrl - Ombi base URL for link generation (optional)
|
||||
* @returns {Array} Array of download objects for the user
|
||||
*/
|
||||
async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap, ombiRetriever, ombiBaseUrl }) {
|
||||
// Input validation
|
||||
if (!cacheSnapshot || typeof cacheSnapshot !== 'object') {
|
||||
console.error('[DownloadBuilder] Invalid cacheSnapshot provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle null/undefined cache data
|
||||
const sabnzbdQueue = cacheSnapshot.sabnzbdQueue || { data: { queue: { slots: [] } } };
|
||||
const sabnzbdHistory = cacheSnapshot.sabnzbdHistory || { data: { history: { slots: [] } } };
|
||||
const sonarrQueue = cacheSnapshot.sonarrQueue || { data: { records: [] } };
|
||||
const sonarrHistory = cacheSnapshot.sonarrHistory || { data: { records: [] } };
|
||||
const radarrQueue = cacheSnapshot.radarrQueue || { data: { records: [] } };
|
||||
const radarrHistory = cacheSnapshot.radarrHistory || { data: { records: [] } };
|
||||
const qbittorrentTorrents = cacheSnapshot.qbittorrentTorrents || [];
|
||||
|
||||
// Get queue status for SABnzbd
|
||||
const queueStatus = sabnzbdQueue.data?.queue?.status || null;
|
||||
const queueSpeed = sabnzbdQueue.data?.queue?.speed || null;
|
||||
const queueKbpersec = sabnzbdQueue.data?.queue?.kbpersec || null;
|
||||
|
||||
// Build context for matching functions
|
||||
const context = {
|
||||
sonarrQueueRecords: sonarrQueue.data?.records || [],
|
||||
sonarrHistoryRecords: sonarrHistory.data?.records || [],
|
||||
radarrQueueRecords: radarrQueue.data?.records || [],
|
||||
radarrHistoryRecords: radarrHistory.data?.records || [],
|
||||
seriesMap: seriesMap || new Map(),
|
||||
moviesMap: moviesMap || new Map(),
|
||||
sonarrTagMap: sonarrTagMap || new Map(),
|
||||
radarrTagMap: radarrTagMap || new Map(),
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap: embyUserMap || new Map(),
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
};
|
||||
|
||||
// Match all download sources
|
||||
const userDownloads = [];
|
||||
const seenDownloadKeys = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
for (const dl of sabMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sabnzbdHistory.data?.history?.slots) {
|
||||
const sabHistoryMatches = await DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
for (const dl of sabHistoryMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
|
||||
return userDownloads;
|
||||
} catch (error) {
|
||||
console.error('[DownloadBuilder] Error building user downloads:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildUserDownloads
|
||||
};
|
||||
@@ -0,0 +1,612 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* DownloadMatcher - Matches download client data to Sonarr/Radarr activity.
|
||||
* Contains logic for matching SABnzbd slots and qBittorrent torrents to media metadata
|
||||
* via download IDs and title matching.
|
||||
*/
|
||||
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const TagMatcher = require('./TagMatcher');
|
||||
const DownloadAssembler = require('./DownloadAssembler');
|
||||
|
||||
/**
|
||||
* Builds a Map of series metadata from Sonarr queue and history records.
|
||||
* @param {Array} queueRecords - Sonarr queue records
|
||||
* @param {Array} historyRecords - Sonarr history records
|
||||
* @returns {Map} Map of seriesId to series object
|
||||
*/
|
||||
function buildSeriesMapFromRecords(queueRecords, historyRecords) {
|
||||
const seriesMap = new Map();
|
||||
for (const r of queueRecords) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
for (const r of historyRecords) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
return seriesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Map of movie metadata from Radarr queue and history records.
|
||||
* @param {Array} queueRecords - Radarr queue records
|
||||
* @param {Array} historyRecords - Radarr history records
|
||||
* @returns {Map} Map of movieId to movie object
|
||||
*/
|
||||
function buildMoviesMapFromRecords(queueRecords, historyRecords) {
|
||||
const moviesMap = new Map();
|
||||
for (const r of queueRecords) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
for (const r of historyRecords) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
return moviesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an Ombi details link to a download object using the TMDB ID from the *arr media object.
|
||||
* No Ombi API call is required — the link is built directly from the TMDB ID.
|
||||
* @param {Object} downloadObj - Download object to enhance
|
||||
* @param {Object} seriesOrMovie - Series or movie object from Sonarr/Radarr
|
||||
* @param {Object} context - Context containing ombiBaseUrl
|
||||
*/
|
||||
function addOmbiMatching(downloadObj, seriesOrMovie, context) {
|
||||
const { ombiBaseUrl } = context;
|
||||
const link = DownloadAssembler.getOmbiDetailsLink(seriesOrMovie, downloadObj.type, ombiBaseUrl);
|
||||
if (link) {
|
||||
downloadObj.ombiLink = link;
|
||||
downloadObj.ombiTooltip = 'View in Ombi';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the status and speed for a SABnzbd slot based on queue state.
|
||||
* @param {Object} slot - SABnzbd queue slot
|
||||
* @param {string} queueStatus - Overall queue status (e.g., 'Paused')
|
||||
* @param {string} queueSpeed - Queue speed string
|
||||
* @param {string} queueKbpersec - Queue speed in KB/s
|
||||
* @returns {Object} Object with status and speed properties
|
||||
*/
|
||||
function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) {
|
||||
if (queueStatus === 'Paused') {
|
||||
return { status: 'Paused', speed: '0' };
|
||||
}
|
||||
return {
|
||||
status: slot.status || 'Unknown',
|
||||
speed: queueSpeed || queueKbpersec || '0'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches SABnzbd queue slots to Sonarr/Radarr activity using download IDs and title matching.
|
||||
* @param {Array} slots - SABnzbd queue slots
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
async function matchSabSlots(slots, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
for (const slot of slots) {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
|
||||
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
// Normalize SAB name (dots to spaces) for better matching
|
||||
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
||||
|
||||
// Try to match by downloadId first (most reliable)
|
||||
const sabDownloadId = slot.nzo_id || slot.id;
|
||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
|
||||
// Also check HISTORY by downloadId
|
||||
if (!sonarrMatch && sabDownloadId) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
||||
}
|
||||
if (!radarrMatch && sabDownloadId) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Also check HISTORY (completed downloads) if no queue match
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbleft,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbleft,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches SABnzbd history slots to Sonarr/Radarr activity using title matching.
|
||||
* @param {Array} slots - SABnzbd history slots
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
async function matchSabHistory(slots, context) {
|
||||
const {
|
||||
sonarrHistoryRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
for (const slot of slots) {
|
||||
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches qBittorrent torrents to Sonarr/Radarr activity using title matching.
|
||||
* @param {Array} torrents - qBittorrent torrent list
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
async function matchTorrents(torrents, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
for (const torrent of torrents) {
|
||||
const torrentName = torrent.name || '';
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
let matchedAny = false;
|
||||
|
||||
const sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrHistoryMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
getSlotStatusAndSpeed,
|
||||
addOmbiMatching,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('../utils/cache');
|
||||
|
||||
// Return all resolved tag labels for a series/movie.
|
||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
||||
// For Sonarr: tags are objects with a label property.
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) {
|
||||
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
}
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
// Return the tag label that matches the current username, or null.
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Check if a tag matches the username: exact match first, then sanitized match
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
// Exact match (handles users whose tags weren't mangled)
|
||||
if (tagLower === username) return true;
|
||||
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
// Build map: both raw lowercase and sanitized form -> display name
|
||||
const map = new Map();
|
||||
for (const u of response.data) {
|
||||
const name = u.Name || '';
|
||||
map.set(name.toLowerCase(), name);
|
||||
map.set(sanitizeTagLabel(name), name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each tag label: matched to a known Emby user, or unmatched.
|
||||
// Returns array of { label, matchedUser: string|null }
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser: displayName };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractAllTags,
|
||||
extractUserTag,
|
||||
sanitizeTagLabel,
|
||||
tagMatchesUser,
|
||||
getEmbyUsers,
|
||||
buildTagBadges
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in a Sonarr/Radarr instance.
|
||||
* @param {Object} instance - The Sonarr/Radarr instance config
|
||||
* @param {string} type - 'Sonarr' or 'Radarr'
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkWebhookConfigured(instance, type) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
const notifications = response.data || [];
|
||||
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check ${type} webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate webhook metrics for a service type.
|
||||
* @param {Object} metricsMap - Map of instance URLs to their metrics
|
||||
* @param {boolean} configured - Whether the service is configured
|
||||
* @returns {Object|null} Aggregated metrics or null if not configured
|
||||
*/
|
||||
function aggregateMetrics(metricsMap, configured) {
|
||||
const values = Object.values(metricsMap);
|
||||
if (values.length === 0) {
|
||||
// Return default metrics if configured but no events yet
|
||||
return configured ? {
|
||||
enabled: true,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0,
|
||||
lastEvent: null
|
||||
} : null;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
|
||||
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
|
||||
lastEvent: values.reduce((latest, m) => {
|
||||
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
|
||||
}, 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||
* @param {Object} instance - The Ombi instance config
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkOmbiWebhookConfigured(instance) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||
headers: { 'ApiKey': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
return !!(response.data && response.data.enabled);
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebhookConfigured,
|
||||
checkOmbiWebhookConfigured,
|
||||
aggregateMetrics
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
const cache = require('./cache');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
|
||||
const TagMatcher = require('../services/TagMatcher');
|
||||
|
||||
// Import retriever classes
|
||||
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
||||
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
||||
const OmbiRetriever = require('../clients/OmbiRetriever');
|
||||
|
||||
// Retriever type mapping
|
||||
const retrieverClasses = {
|
||||
sonarr: PollingSonarrRetriever,
|
||||
radarr: PollingRadarrRetriever,
|
||||
ombi: OmbiRetriever
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton registry for *arr data retrievers
|
||||
*/
|
||||
const arrRetrieverRegistry = {
|
||||
retrievers: new Map(),
|
||||
initialized: false,
|
||||
|
||||
/**
|
||||
* Initialize all configured *arr retrievers
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...');
|
||||
|
||||
// Get all instance configurations
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
// Create retriever instances
|
||||
const instanceConfigs = [
|
||||
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' })),
|
||||
...ombiInstances.map(inst => ({ ...inst, type: 'ombi' }))
|
||||
];
|
||||
|
||||
for (const config of instanceConfigs) {
|
||||
try {
|
||||
const RetrieverClass = retrieverClasses[config.type];
|
||||
if (!RetrieverClass) {
|
||||
logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retriever = new RetrieverClass(config);
|
||||
const uniqueKey = `${config.type}:${config.id}`;
|
||||
this.retrievers.set(uniqueKey, retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all registered retrievers
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getAllRetrievers() {
|
||||
return Array.from(this.retrievers.values());
|
||||
},
|
||||
|
||||
/**
|
||||
* Get retriever by instance ID
|
||||
* @param {string} instanceId - The instance ID
|
||||
* @returns {ArrRetriever|null} Retriever instance or null if not found
|
||||
*/
|
||||
getRetriever(instanceId) {
|
||||
return this.retrievers.get(instanceId) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get retrievers by type
|
||||
* @param {string} type - Retriever type ('sonarr', 'radarr')
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getRetrieversByType(type) {
|
||||
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of tag results with instance info
|
||||
*/
|
||||
async getAllTags() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch tags from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get queue from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of queue results with instance info
|
||||
*/
|
||||
async getAllQueues() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch queues from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get history from all retrievers
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Array<Object>>} Array of history results with instance info
|
||||
*/
|
||||
async getAllHistory(options = {}) {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch history from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags grouped by retriever type
|
||||
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
|
||||
*/
|
||||
async getTagsByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrTags = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrTags = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get queue grouped by retriever type
|
||||
* @returns {Promise<Object>} Queue grouped by retriever type
|
||||
*/
|
||||
async getQueuesByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrQueues = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrQueues = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get history grouped by retriever type
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Object>} History grouped by retriever type
|
||||
*/
|
||||
async getHistoryByType(options = {}) {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrHistory = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrHistory = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Ombi retrievers
|
||||
* @returns {Array<OmbiRetriever>} Array of Ombi retriever instances
|
||||
*/
|
||||
getOmbiRetrievers() {
|
||||
return this.getRetrieversByType('ombi');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all Ombi requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||
*/
|
||||
async getOmbiRequests(force = false) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return { movie: [], tv: [] };
|
||||
}
|
||||
|
||||
// Use the first Ombi retriever (single instance expected)
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
const movieRequests = await retriever.getMovieRequests(force);
|
||||
const tvRequests = await retriever.getTvRequests(false);
|
||||
return { movie: movieRequests, tv: tvRequests };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||
return { movie: [], tv: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Ombi requests grouped by type
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||
*/
|
||||
async getOmbiRequestsByType(force = false) {
|
||||
return await this.getOmbiRequests(force);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find Ombi request by external IDs
|
||||
* @param {string} type - 'movie' or 'tv'
|
||||
* @param {Object} externalIds - External IDs to search with
|
||||
* @param {string} externalIds.tmdbId - TheMovieDB ID
|
||||
* @param {string} externalIds.tvdbId - TheTVDB ID (for TV)
|
||||
* @param {string} externalIds.imdbId - IMDB ID (for movies)
|
||||
* @returns {Promise<Object|null>} Ombi request object or null if not found
|
||||
*/
|
||||
async findOmbiRequest(type, externalIds) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
if (type === 'movie') {
|
||||
return await retriever.findMovieRequest(externalIds.tmdbId, externalIds.imdbId);
|
||||
} else if (type === 'tv') {
|
||||
return await retriever.findTvRequest(externalIds.tvdbId, externalIds.tmdbId);
|
||||
}
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error finding Ombi request: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Matching / aggregation helper function to compare a download item and an *arr item.
|
||||
*/
|
||||
function matchDownload(download, arrItem, username, tagMap) {
|
||||
if (!download || !arrItem) return false;
|
||||
|
||||
// 1. First attempt an exact ID match using the stable fields that exist in the fetched data
|
||||
if (download.arrInfo) {
|
||||
// Sonarr stable IDs
|
||||
if (download.arrInfo.episodeFileId !== undefined && arrItem.episodeFileId !== undefined) {
|
||||
if (download.arrInfo.episodeFileId === arrItem.episodeFileId) return true;
|
||||
}
|
||||
if (download.arrInfo.episodeId !== undefined && arrItem.episodeId !== undefined) {
|
||||
if (download.arrInfo.episodeId === arrItem.episodeId) return true;
|
||||
}
|
||||
if (download.arrInfo.seriesId !== undefined && arrItem.seriesId !== undefined) {
|
||||
if (download.arrInfo.seriesId === arrItem.seriesId) return true;
|
||||
}
|
||||
|
||||
// Radarr stable IDs
|
||||
if (download.arrInfo.movieFileId !== undefined && arrItem.movieFileId !== undefined) {
|
||||
if (download.arrInfo.movieFileId === arrItem.movieFileId) return true;
|
||||
}
|
||||
if (download.arrInfo.movieId !== undefined && arrItem.movieId !== undefined) {
|
||||
if (download.arrInfo.movieId === arrItem.movieId) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Only fall back to the existing title + tag string matching if no ID match is possible
|
||||
const dlTitle = (download.title || '').toLowerCase();
|
||||
const arrTitle = (arrItem.title || arrItem.sourceTitle || '').toLowerCase();
|
||||
const titleMatches = dlTitle && arrTitle && (dlTitle.includes(arrTitle) || arrTitle.includes(dlTitle));
|
||||
|
||||
if (!titleMatches) return false;
|
||||
|
||||
// Preserve the existing lowercase-username tag logic exactly
|
||||
if (!username) return true;
|
||||
|
||||
const getLabels = (item) => {
|
||||
if (!item) return [];
|
||||
const tags = item.tags || (item.series && item.series.tags) || (item.movie && item.movie.tags) || [];
|
||||
return tags.map(t => {
|
||||
if (typeof t === 'object' && t !== null) {
|
||||
return t.label || t.name;
|
||||
}
|
||||
if (tagMap && tagMap.has && tagMap.has(t)) {
|
||||
return tagMap.get(t);
|
||||
}
|
||||
|
||||
// Try resolving from cache as fallback
|
||||
const cachedSonarrTags = cache.get('poll:sonarr-tags') || [];
|
||||
const cachedRadarrTags = cache.get('poll:radarr-tags') || [];
|
||||
const allCachedTags = [...cachedSonarrTags, ...cachedRadarrTags];
|
||||
const found = allCachedTags.find(tag => tag && tag.id === t);
|
||||
if (found) return found.label || found.name;
|
||||
|
||||
return t;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const dlTags = getLabels(download);
|
||||
const arrTags = getLabels(arrItem);
|
||||
const allTags = [...dlTags, ...arrTags];
|
||||
|
||||
return allTags.some(tag => TagMatcher.tagMatchesUser(tag, username.toLowerCase()));
|
||||
}
|
||||
|
||||
// Attach matching helper functions to the registry object
|
||||
arrRetrieverRegistry.matchDownload = matchDownload;
|
||||
arrRetrieverRegistry.matchDownloadToArr = matchDownload;
|
||||
arrRetrieverRegistry.aggregateMatch = matchDownload;
|
||||
arrRetrieverRegistry.matchingHelper = matchDownload;
|
||||
arrRetrieverRegistry.compareDownloadAndArr = matchDownload;
|
||||
|
||||
module.exports = arrRetrieverRegistry;
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
class MemoryCache {
|
||||
@@ -71,4 +72,64 @@ class MemoryCache {
|
||||
|
||||
const cache = new MemoryCache();
|
||||
|
||||
// Webhook metrics for polling optimization
|
||||
// These are stored separately from regular cache entries
|
||||
const webhookMetrics = {
|
||||
// Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped }
|
||||
instances: new Map(),
|
||||
// Global metrics
|
||||
lastGlobalWebhookTimestamp: null,
|
||||
totalWebhookEventsReceived: 0
|
||||
};
|
||||
|
||||
function getWebhookMetrics(instanceUrl) {
|
||||
if (!instanceUrl) return null;
|
||||
return webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
}
|
||||
|
||||
function updateWebhookMetrics(instanceUrl) {
|
||||
const now = Date.now();
|
||||
webhookMetrics.lastGlobalWebhookTimestamp = now;
|
||||
webhookMetrics.totalWebhookEventsReceived++;
|
||||
|
||||
if (instanceUrl) {
|
||||
const metrics = webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
metrics.lastWebhookTimestamp = now;
|
||||
metrics.eventsReceived++;
|
||||
webhookMetrics.instances.set(instanceUrl, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementPollsSkipped(instanceUrl) {
|
||||
if (instanceUrl) {
|
||||
const metrics = webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
metrics.pollsSkipped++;
|
||||
webhookMetrics.instances.set(instanceUrl, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalWebhookMetrics() {
|
||||
return {
|
||||
lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp,
|
||||
totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived,
|
||||
instances: Object.fromEntries(webhookMetrics.instances)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = cache;
|
||||
module.exports.getWebhookMetrics = getWebhookMetrics;
|
||||
module.exports.updateWebhookMetrics = updateWebhookMetrics;
|
||||
module.exports.incrementPollsSkipped = incrementPollsSkipped;
|
||||
module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
// Validate that a configured service URL is well-formed and uses http(s).
|
||||
@@ -84,6 +84,14 @@ function getRadarrInstances() {
|
||||
);
|
||||
}
|
||||
|
||||
function getOmbiInstances() {
|
||||
return parseInstances(
|
||||
process.env.OMBI_INSTANCES,
|
||||
process.env.OMBI_URL,
|
||||
process.env.OMBI_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getQbittorrentInstances() {
|
||||
return parseInstances(
|
||||
process.env.QBITTORRENT_INSTANCES,
|
||||
@@ -94,11 +102,49 @@ function getQbittorrentInstances() {
|
||||
);
|
||||
}
|
||||
|
||||
function getTransmissionInstances() {
|
||||
return parseInstances(
|
||||
process.env.TRANSMISSION_INSTANCES,
|
||||
process.env.TRANSMISSION_URL,
|
||||
null, // no apiKey for Transmission
|
||||
process.env.TRANSMISSION_USERNAME,
|
||||
process.env.TRANSMISSION_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
function getRtorrentInstances() {
|
||||
return parseInstances(
|
||||
process.env.RTORRENT_INSTANCES,
|
||||
process.env.RTORRENT_URL,
|
||||
null, // no apiKey for rtorrent
|
||||
process.env.RTORRENT_USERNAME,
|
||||
process.env.RTORRENT_PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
function getWebhookSecret() {
|
||||
return process.env.SOFARR_WEBHOOK_SECRET || '';
|
||||
}
|
||||
|
||||
function getSofarrBaseUrl() {
|
||||
return process.env.SOFARR_BASE_URL || '';
|
||||
}
|
||||
|
||||
function getSofarrWebhookBaseUrl() {
|
||||
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getOmbiInstances,
|
||||
getQbittorrentInstances,
|
||||
getTransmissionInstances,
|
||||
getRtorrentInstances,
|
||||
getWebhookSecret,
|
||||
getSofarrBaseUrl,
|
||||
getSofarrWebhookBaseUrl,
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||