Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,3 +1,4 @@
|
|||||||
|
# Docker build context ignores
|
||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.example
|
.env.example
|
||||||
@@ -7,7 +8,8 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
**/*.log
|
**/*.log
|
||||||
client/
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# RADARR_URL=https://radarr.example.com
|
# RADARR_URL=https://radarr.example.com
|
||||||
# RADARR_API_KEY=your-radarr-api-key
|
# RADARR_API_KEY=your-radarr-api-key
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OMBI (Request Management - Optional)
|
||||||
|
# =============================================================================
|
||||||
|
OMBI_URL=https://ombi.example.com
|
||||||
|
OMBI_API_KEY=your-ombi-api-key-here
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOTES
|
# NOTES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -23,17 +23,34 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
||||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
|
# Primary registry tags
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||||
|
|
||||||
|
# Gitea package registry tags
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
echo "Building release image tags: ${TAGS}"
|
||||||
fi
|
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
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -60,3 +60,54 @@ jobs:
|
|||||||
name: coverage-report
|
name: coverage-report
|
||||||
path: coverage/
|
path: coverage/
|
||||||
retention-days: 14
|
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
|
||||||
|
|||||||
@@ -40,10 +40,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Check licence compatibility
|
- name: Check licence compatibility
|
||||||
run: |
|
run: |
|
||||||
npx --yes license-checker --production \
|
# First, output all production licenses for visibility
|
||||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
echo "Checking production dependency licenses..."
|
||||||
--excludePrivatePackages \
|
npx --yes license-checker --production --excludePrivatePackages --json > /tmp/licenses.json
|
||||||
&& echo "All production dependency licences are compatible with MIT."
|
|
||||||
|
# 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
|
- name: Check copyright headers in source files
|
||||||
run: |
|
run: |
|
||||||
@@ -56,6 +67,7 @@ jobs:
|
|||||||
! -path "./.git/*" \
|
! -path "./.git/*" \
|
||||||
! -path "./dist/*" \
|
! -path "./dist/*" \
|
||||||
! -path "./build/*" \
|
! -path "./build/*" \
|
||||||
|
! -path "./public/*" \
|
||||||
! -path "./.gitea/*")
|
! -path "./.gitea/*")
|
||||||
|
|
||||||
MISSING_HEADER=0
|
MISSING_HEADER=0
|
||||||
@@ -70,6 +82,9 @@ jobs:
|
|||||||
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
|
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
|
||||||
echo "❌ Missing MIT-compliant copyright header in: $file"
|
echo "❌ Missing MIT-compliant copyright header in: $file"
|
||||||
echo " Required format: // Copyright (c) YYYY Name. MIT License."
|
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))
|
MISSING_HEADER=$((MISSING_HEADER + 1))
|
||||||
fi
|
fi
|
||||||
done <<< "$SOURCE_FILES"
|
done <<< "$SOURCE_FILES"
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ data/
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
.agents/
|
||||||
|
.windsurf/
|
||||||
@@ -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
|
||||||
@@ -37,6 +37,7 @@ Three pluggable layers form the architectural core:
|
|||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
|
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
|
||||||
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
|
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
|
||||||
|
| Download matching & assembly | **Download Services** | `server/services/` |
|
||||||
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
|
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,6 +51,9 @@ flowchart TB
|
|||||||
dash["Dashboard Cards"]
|
dash["Dashboard Cards"]
|
||||||
status["Status Panel\n(Admin only)"]
|
status["Status Panel\n(Admin only)"]
|
||||||
history["History Tab"]
|
history["History Tab"]
|
||||||
|
requests["Requests Tab\n+ Filters / Search"]
|
||||||
|
webhooks["Webhook Config"]
|
||||||
|
swagger["Swagger UI\n/api/swagger"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Server["Express Server (:3001)"]
|
subgraph Server["Express Server (:3001)"]
|
||||||
@@ -57,7 +61,9 @@ flowchart TB
|
|||||||
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
|
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
|
||||||
auth_r["Auth Routes\n/api/auth"]
|
auth_r["Auth Routes\n/api/auth"]
|
||||||
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
||||||
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
|
stat_r["Status Routes\n/api/status"]
|
||||||
|
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr|ombi"]
|
||||||
|
ombi_r["Ombi Routes\n/api/ombi"]
|
||||||
hist_r["History Routes\n/api/history"]
|
hist_r["History Routes\n/api/history"]
|
||||||
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
||||||
|
|
||||||
@@ -78,27 +84,31 @@ flowchart TB
|
|||||||
rtorrent["rTorrent"]
|
rtorrent["rTorrent"]
|
||||||
transmission["Transmission"]
|
transmission["Transmission"]
|
||||||
emby["Emby / Jellyfin"]
|
emby["Emby / Jellyfin"]
|
||||||
|
ombi["Ombi"]
|
||||||
end
|
end
|
||||||
|
|
||||||
login -->|"POST /api/auth/login"| auth_r
|
login -->|"POST /api/auth/login"| auth_r
|
||||||
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
||||||
status -->|"GET /api/dashboard/status"| dash_r
|
status -->|"GET /api/status"| stat_r
|
||||||
history -->|"GET /api/history/recent"| hist_r
|
history -->|"GET /api/history/recent"| hist_r
|
||||||
|
requests -->|"GET /api/ombi/requests"| ombi_r
|
||||||
|
|
||||||
auth_r --> tokenstore
|
auth_r --> tokenstore
|
||||||
auth_r -->|"authenticate"| emby
|
auth_r -->|"authenticate"| emby
|
||||||
|
|
||||||
dash_r --> cache
|
dash_r --> cache
|
||||||
dash_r --> poller
|
dash_r --> poller
|
||||||
|
stat_r --> cache
|
||||||
wh_r --> cache
|
wh_r --> cache
|
||||||
wh_r --> paldra
|
wh_r --> paldra
|
||||||
|
ombi_r --> paldra
|
||||||
hist_r --> cache
|
hist_r --> cache
|
||||||
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
|
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
|
||||||
|
|
||||||
poller --> pdca & paldra
|
poller --> pdca & paldra
|
||||||
poller --> cache
|
poller --> cache
|
||||||
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
|
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
|
||||||
paldra -->|"HTTP/API"| sonarr & radarr
|
paldra -->|"HTTP/API"| sonarr & radarr & ombi
|
||||||
|
|
||||||
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
|
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
|
||||||
```
|
```
|
||||||
@@ -109,7 +119,7 @@ flowchart TB
|
|||||||
Browser (SPA)
|
Browser (SPA)
|
||||||
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
|
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
|
||||||
│ GET /api/dashboard/stream → SSE stream → cache → matched downloads
|
│ GET /api/dashboard/stream → SSE stream → cache → matched downloads
|
||||||
│ POST /api/webhook/* ← Sonarr/Radarr push events
|
│ POST /api/webhook/* ← Sonarr/Radarr/Ombi push events
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Express Server (:3001)
|
Express Server (:3001)
|
||||||
@@ -118,13 +128,18 @@ Express Server (:3001)
|
|||||||
├── cookie-parser (HMAC-signed session cookie)
|
├── cookie-parser (HMAC-signed session cookie)
|
||||||
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
|
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
|
||||||
│
|
│
|
||||||
|
├── /api/swagger → Swagger UI (public, auth banner for testing)
|
||||||
├── /api/auth → login, logout, me, csrf
|
├── /api/auth → login, logout, me, csrf
|
||||||
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
|
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
|
||||||
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
|
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
|
||||||
├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON
|
│ → /config: GET endpoint for configuration status validation
|
||||||
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON
|
||||||
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
├── /api/status → requireAuth → admin cache/polling/webhook status
|
||||||
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
||||||
|
├── /api/ombi → requireAuth → PALDRA → filter/sort/search → JSON
|
||||||
|
│ → /webhook/*: enable (POST), status (GET), and test (POST) endpoints
|
||||||
|
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
||||||
|
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Poller (setInterval POLL_INTERVAL ms)
|
Poller (setInterval POLL_INTERVAL ms)
|
||||||
@@ -239,7 +254,7 @@ Each `QBittorrentClient` instance maintains:
|
|||||||
|
|
||||||
Per-cycle flow:
|
Per-cycle flow:
|
||||||
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
|
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
|
||||||
2. If `full_update` is `true`, rebuild `torrentMap` from scratch.
|
2. If `full_update` is `true`, rebuild `torrentMap` from scratch (resets incremental state to prevent corruption).
|
||||||
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
|
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
|
||||||
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
|
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
|
||||||
5. If the fallback also fails, return an empty array for this cycle and log the error.
|
5. If the fallback also fails, return an empty array for this cycle and log the error.
|
||||||
@@ -258,10 +273,19 @@ The rest of the application (poller, dashboard) receives data in the same format
|
|||||||
|
|
||||||
#### Overview
|
#### Overview
|
||||||
|
|
||||||
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever` or `PollingRadarrRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
|
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever`, `PollingRadarrRetriever`, or `OmbiRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
|
||||||
|
|
||||||
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
|
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
|
||||||
|
|
||||||
|
**Supported Retrievers:**
|
||||||
|
- **PollingSonarrRetriever**: TV series data from Sonarr instances
|
||||||
|
- **PollingRadarrRetriever**: Movie data from Radarr instances
|
||||||
|
- **OmbiRetriever**: Request management data from Ombi instances
|
||||||
|
|
||||||
|
#### Error handling
|
||||||
|
|
||||||
|
Retriever methods now throw on HTTP failure rather than swallowing errors silently. This ensures the poller logs upstream problems and skips the affected instance cleanly instead of caching stale empty data.
|
||||||
|
|
||||||
#### Registry API
|
#### Registry API
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@@ -269,15 +293,32 @@ arrRetrieverRegistry = {
|
|||||||
async initialize() // idempotent; reads config once
|
async initialize() // idempotent; reads config once
|
||||||
getAllRetrievers(): ArrRetriever[]
|
getAllRetrievers(): ArrRetriever[]
|
||||||
getRetriever(instanceId): ArrRetriever | null
|
getRetriever(instanceId): ArrRetriever | null
|
||||||
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr'
|
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr' | 'ombi'
|
||||||
|
|
||||||
// Typed fetch methods — all return { sonarr: [...], radarr: [...] }
|
// Typed fetch methods — all return { sonarr: [...], radarr: [...] }
|
||||||
async getQueuesByType(): Promise<{ sonarr, radarr }>
|
async getQueuesByType(): Promise<{ sonarr, radarr }>
|
||||||
async getHistoryByType(options?): Promise<{ sonarr, radarr }>
|
async getHistoryByType(options?): Promise<{ sonarr, radarr }>
|
||||||
async getTagsByType(): Promise<{ sonarr, radarr }>
|
async getTagsByType(): Promise<{ sonarr, radarr }>
|
||||||
|
|
||||||
|
// Ombi-specific methods
|
||||||
|
getOmbiRetrievers(): OmbiRetriever[]
|
||||||
|
async getOmbiRequests(): Promise<{ movie: [], tv: [] }>
|
||||||
|
async getOmbiRequestsByType(): Promise<{ movie: [], tv: [] }>
|
||||||
|
async findOmbiRequest(type, externalIds): Promise<Object | null>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Ombi retriever
|
||||||
|
|
||||||
|
The `OmbiRetriever` (in `server/clients/OmbiClient.js`) fetches from:
|
||||||
|
|
||||||
|
| Task | Endpoint | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Movie requests | `GET /api/v1/Request/movie` | Returns full movie request objects |
|
||||||
|
| TV requests | `GET /api/v1/Request/tv` | Returns full TV request objects |
|
||||||
|
|
||||||
|
Results are cached under `poll:ombi` and broadcast via SSE as `ombiRequests: { movie, tv }`. The client applies the same `ombiFilters.js` logic used by the server route, keeping behaviour consistent across both layers.
|
||||||
|
|
||||||
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
|
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
|
||||||
|
|
||||||
#### Retriever API Calls
|
#### Retriever API Calls
|
||||||
@@ -285,13 +326,55 @@ Each result element is `{ instance: instanceId, data: <arr API response> }`, all
|
|||||||
| Task | Endpoint | Key Parameters |
|
| Task | Endpoint | Key Parameters |
|
||||||
|------|----------|----------------|
|
|------|----------|----------------|
|
||||||
| Sonarr tags | `GET /api/v3/tag` | — |
|
| Sonarr tags | `GET /api/v3/tag` | — |
|
||||||
| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
|
| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true`, `pageSize=1000` (paginated up to 50 pages) |
|
||||||
| Sonarr history | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
|
| Sonarr history | `GET /api/v3/history` | `pageSize=50` (poller), `maxPages=1` (default), `includeEpisode=true` |
|
||||||
| Radarr tags | `GET /api/v3/tag` | — |
|
| Radarr tags | `GET /api/v3/tag` | — |
|
||||||
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true` |
|
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true`, `pageSize=1000` (paginated up to 50 pages) |
|
||||||
| Radarr history | `GET /api/v3/history` | `pageSize=10` |
|
| Radarr history | `GET /api/v3/history` | `pageSize=50` (poller), `maxPages=1` (default) |
|
||||||
|
|
||||||
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others.
|
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others. Queue fetches automatically paginate through all available records; history fetches default to a single page to avoid multi-second response times, while the UI history fetcher uses 100 records per page.
|
||||||
|
|
||||||
|
### 3.3 Download Matching & Assembly Services
|
||||||
|
|
||||||
|
#### Overview
|
||||||
|
|
||||||
|
The `server/services/` directory contains pure, testable services that transform raw cache data into user-facing download objects. These were extracted from `dashboard.js` during the technical-debt remediation, reducing the route file from ~1,360 lines to ~284 lines.
|
||||||
|
|
||||||
|
#### Service hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
DownloadBuilder.js Orchestrator: reads cache snapshot, calls matchers, deduplicates results
|
||||||
|
├── DownloadMatcher.js Matches SABnzbd slots and qBittorrent torrents to Sonarr/Radarr records
|
||||||
|
│ ├── matchSabSlots() Queue slots: matches by downloadId first, then bidirectional title substring
|
||||||
|
│ ├── matchSabHistory() History slots: title matching against Sonarr/Radarr history
|
||||||
|
│ └── matchTorrents() Torrents: queue → history fallback; unmatched torrents are excluded
|
||||||
|
├── DownloadAssembler.js Pure helpers for building download objects
|
||||||
|
│ ├── getCoverArt() Poster/fanart resolution
|
||||||
|
│ ├── getImportIssues() Warning/error message extraction
|
||||||
|
│ ├── getSonarrLink() / getRadarrLink()
|
||||||
|
│ ├── canBlocklist() Admin vs non-admin blocklist eligibility
|
||||||
|
│ ├── extractEpisode() Season/episode/title from queue/history record
|
||||||
|
│ └── gatherEpisodes() Collect all episodes sharing the same download title
|
||||||
|
└── TagMatcher.js Tag extraction, sanitisation, and user matching
|
||||||
|
├── extractAllTags() / extractUserTag()
|
||||||
|
├── tagMatchesUser() Exact or sanitised match (handles Ombi-mangled tags)
|
||||||
|
├── getEmbyUsers() Cached Emby user Map (60 s TTL)
|
||||||
|
└── buildTagBadges() Classify tags for admin showAll view
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Matching priority
|
||||||
|
|
||||||
|
For each download item, the matcher attempts matches in priority order:
|
||||||
|
|
||||||
|
1. **Stable ID match** — `downloadId` on SABnzbd slots is compared against Sonarr/Radarr queue/history `downloadId` fields (most reliable).
|
||||||
|
2. **Title substring match** — bidirectional, case-insensitive substring check between the download name and the *arr `title` / `sourceTitle`.
|
||||||
|
3. **Normalised title match** — dots replaced with spaces to handle release-name vs display-title mismatches.
|
||||||
|
|
||||||
|
Unmatched torrents are **not** included in the response (fixed in develop-refactor2).
|
||||||
|
|
||||||
|
#### Deduplication
|
||||||
|
|
||||||
|
`DownloadBuilder.buildUserDownloads()` deduplicates by `${type}:${title}` so the same download does not appear twice when it is present in both queue and history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -299,11 +382,12 @@ All fetches across all instances run in parallel via `Promise.allSettled`, so a
|
|||||||
|
|
||||||
### 4.1 Webhook Receiver
|
### 4.1 Webhook Receiver
|
||||||
|
|
||||||
sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event:
|
sofarr exposes three webhook endpoints that Sonarr, Radarr, and Ombi can be configured to call on automation and request events:
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/webhook/sonarr
|
POST /api/webhook/sonarr
|
||||||
POST /api/webhook/radarr
|
POST /api/webhook/radarr
|
||||||
|
POST /api/webhook/ombi
|
||||||
```
|
```
|
||||||
|
|
||||||
Both endpoints share identical processing logic:
|
Both endpoints share identical processing logic:
|
||||||
@@ -341,6 +425,10 @@ Sonarr/Radarr
|
|||||||
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
|
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Replay protection improvements
|
||||||
|
|
||||||
|
The replay cache uses an atomic `Set`-based deduplication key (`{eventType}:{instanceName}:{date}`) with a 5-minute TTL. `instanceName` precision was tightened so that events from different *arr instances are never incorrectly flagged as duplicates.
|
||||||
|
|
||||||
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
|
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
|
||||||
|
|
||||||
#### Event Classification
|
#### Event Classification
|
||||||
@@ -383,6 +471,8 @@ The dashboard therefore receives fresh data within the round-trip time of the *a
|
|||||||
|
|
||||||
The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL.
|
The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL.
|
||||||
|
|
||||||
|
Similarly, the `ombi.js` route module exposes endpoints under `/api/ombi/webhook/` (including `/enable`, `/status`, and `/test`) to support one-click registration and validation of the Sofarr webhook inside the configured Ombi instance.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Data Flow and Real-time Updates
|
## 5. Data Flow and Real-time Updates
|
||||||
@@ -397,10 +487,10 @@ Every `POLL_INTERVAL` ms the poller fetches all services in parallel:
|
|||||||
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
||||||
| Sonarr Tags | `GET /api/v3/tag` | — |
|
| Sonarr Tags | `GET /api/v3/tag` | — |
|
||||||
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
|
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
|
||||||
| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
|
| Sonarr History | `GET /api/v3/history` | `pageSize=50`, `includeEpisode=true` |
|
||||||
| Radarr Tags | `GET /api/v3/tag` | — |
|
| Radarr Tags | `GET /api/v3/tag` | — |
|
||||||
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
||||||
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
|
| Radarr History | `GET /api/v3/history` | `pageSize=50` |
|
||||||
| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback: `GET /api/v2/torrents/info` |
|
| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback: `GET /api/v2/torrents/info` |
|
||||||
|
|
||||||
Results are stored in `MemoryCache` under `poll:*` keys with TTL `POLL_INTERVAL × 3`. Per-task timings are recorded in `lastPollTimings` for the admin status panel.
|
Results are stored in `MemoryCache` under `poll:*` keys with TTL `POLL_INTERVAL × 3`. Per-task timings are recorded in `lastPollTimings` for the admin status panel.
|
||||||
@@ -460,31 +550,62 @@ When a browser opens `GET /api/dashboard/stream`:
|
|||||||
|
|
||||||
The browser's native `EventSource` API handles reconnection automatically on network interruption.
|
The browser's native `EventSource` API handles reconnection automatically on network interruption.
|
||||||
|
|
||||||
|
**SSE Payload Structure**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
user: string, // Username
|
||||||
|
isAdmin: boolean, // Admin flag
|
||||||
|
downloads: DownloadObject[], // Matched download objects (see Section 5.4)
|
||||||
|
downloadClients: { // Configured download clients for ordering/filtering
|
||||||
|
id: string, // Instance identifier
|
||||||
|
name: string, // Instance display name
|
||||||
|
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
|
||||||
|
}[],
|
||||||
|
ombiRequests: { // Raw Ombi movie + TV requests (client applies filters)
|
||||||
|
movie: OmbiRequest[],
|
||||||
|
tv: OmbiRequest[]
|
||||||
|
},
|
||||||
|
ombiBaseUrl: string // Ombi instance base URL for deep links
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 5.4 Download Matching Pipeline
|
### 5.4 Download Matching Pipeline
|
||||||
|
|
||||||
For each connected user the server:
|
For each connected user the server:
|
||||||
|
|
||||||
1. Reads all `poll:*` keys from `MemoryCache`.
|
1. Reads all `poll:*` keys from `MemoryCache`.
|
||||||
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
|
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
|
||||||
3. For each SABnzbd/qBittorrent download item, attempts matches in priority order: Sonarr queue → Radarr queue → Sonarr history → Radarr history.
|
3. Delegates to `DownloadBuilder.buildUserDownloads(cacheSnapshot, options)`, which orchestrates:
|
||||||
4. Title matching is a **bidirectional, case-insensitive substring match**: `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
|
- `DownloadMatcher.matchSabSlots()` — matches active SABnzbd queue slots
|
||||||
5. For each match, resolves the series/movie, extracts user tags, checks ownership.
|
- `DownloadMatcher.matchSabHistory()` — matches recent SABnzbd history slots
|
||||||
6. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
|
- `DownloadMatcher.matchTorrents()` — matches qBittorrent torrents
|
||||||
|
4. Each matcher attempts matches in priority order:
|
||||||
|
- **Stable ID match** — `downloadId` compared against *arr `downloadId` (most reliable).
|
||||||
|
- **Bidirectional title substring match** — case-insensitive `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
|
||||||
|
- **Normalised title match** — dots replaced with spaces for release-name vs display-title mismatches.
|
||||||
|
5. Unmatched torrents are excluded; matched results are deduplicated by `${type}:${title}`.
|
||||||
|
6. For each match, `DownloadAssembler` resolves cover art, episodes, import issues, blocklist eligibility, and admin fields.
|
||||||
|
7. `TagMatcher` extracts user tags and checks ownership.
|
||||||
|
8. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
|
Start(["Download item"]) --> DLID{"Stable ID match\ndownloadId?"}
|
||||||
|
DLID -->|yes| IDResolve["Resolve series/movie\nfrom queue/history record"]
|
||||||
|
DLID -->|no| SQ{"Sonarr QUEUE\ntitle match?"}
|
||||||
SQ -->|yes| SQR["Resolve series · extract user tag"]
|
SQ -->|yes| SQR["Resolve series · extract user tag"]
|
||||||
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
|
SQ -->|no| RQ{"Radarr QUEUE\ntitle match?"}
|
||||||
RQ -->|yes| RQR["Resolve movie · extract user tag"]
|
RQ -->|yes| RQR["Resolve movie · extract user tag"]
|
||||||
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
|
RQ -->|no| SH{"Sonarr HISTORY\ntitle match?"}
|
||||||
SH -->|yes| SHR["Resolve series via seriesId"]
|
SH -->|yes| SHR["Resolve series via seriesId"]
|
||||||
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
|
SH -->|no| RH{"Radarr HISTORY\ntitle match?"}
|
||||||
RH -->|yes| RHR["Resolve movie via movieId"]
|
RH -->|yes| RHR["Resolve movie via movieId"]
|
||||||
RH -->|no| Skip(["Skip — unmatched"])
|
RH -->|no| Skip(["Skip — unmatched"])
|
||||||
|
|
||||||
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
|
IDResolve & SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
|
||||||
Tagged -->|yes| Include(["Include in response"])
|
Tagged -->|yes| Dedup["Deduplicate by type:title"]
|
||||||
|
Dedup --> Include(["Include in response"])
|
||||||
Tagged -->|no| Skip
|
Tagged -->|no| Skip
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -495,6 +616,14 @@ Users are matched to downloads via Sonarr/Radarr tags:
|
|||||||
1. **Exact match** — tag label (lowercased) === username (lowercased).
|
1. **Exact match** — tag label (lowercased) === username (lowercased).
|
||||||
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
|
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
|
||||||
|
|
||||||
|
#### Client ordering and filtering
|
||||||
|
|
||||||
|
Matched download objects include `client`, `instanceId`, and `instanceName` fields. The frontend:
|
||||||
|
1. Receives a `downloadClients` array from the SSE payload with all configured clients in configuration order
|
||||||
|
2. Displays a multi-select filter allowing users to choose which clients to view
|
||||||
|
3. Sorts downloads by client order (downloads from the first configured client appear first)
|
||||||
|
4. Filters downloads to show only those from selected client instances
|
||||||
|
|
||||||
#### Matched download object fields
|
#### Matched download object fields
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -523,9 +652,73 @@ Users are matched to downloads via Sonarr/Radarr tags:
|
|||||||
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
|
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
|
||||||
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
|
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
|
||||||
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
|
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
|
||||||
|
| `client` | string | Download client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent') |
|
||||||
|
| `instanceId` | string | Instance identifier matching the configured client ID |
|
||||||
|
| `instanceName` | string | Instance display name from configuration |
|
||||||
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
|
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
|
||||||
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
|
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
|
||||||
|
|
||||||
|
### 5.5 Ombi Request Filtering
|
||||||
|
|
||||||
|
The Ombi Requests tab displays movie and TV requests from Ombi. Filtering, sorting, and text search are applied **server-side** on the REST endpoint (`GET /api/ombi/requests`) and **client-side** on every SSE update. This dual-layer approach ensures external API consumers receive pre-filtered data while the SPA remains responsive without extra round-trips.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Browser (Requests Tab)
|
||||||
|
participant SSE as SSE /api/dashboard/stream
|
||||||
|
participant Route as /api/ombi/requests
|
||||||
|
participant Filters as ombiFilters (shared)
|
||||||
|
participant PALDRA as PALDRA Registry
|
||||||
|
participant Ombi as Ombi API
|
||||||
|
|
||||||
|
Note over Client: Initial load
|
||||||
|
Client->>Route: GET /api/ombi/requests?type=…&status=…&sort=…&search=…
|
||||||
|
Route->>PALDRA: getOmbiRequests()
|
||||||
|
PALDRA->>Ombi: GET /api/v1/Request/movie + /tv
|
||||||
|
Ombi-->>PALDRA: raw request arrays
|
||||||
|
PALDRA-->>Route: { movie: [], tv: [] }
|
||||||
|
Route->>Filters: applyRequestFilters()
|
||||||
|
Filters-->>Route: filtered & sorted requests
|
||||||
|
Route-->>Client: { requests: { movie, tv }, total }
|
||||||
|
|
||||||
|
Note over Client: Real-time updates
|
||||||
|
SSE->>Client: push raw ombiRequests + ombiBaseUrl
|
||||||
|
Client->>Filters: applyRequestFilters() (same code)
|
||||||
|
Filters-->>Client: filtered & sorted requests
|
||||||
|
Client->>Client: renderRequests()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Query parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `type` | `movie` \| `tv` \| `all` | `all` | Media type filter (multi-select) |
|
||||||
|
| `status` | `pending` \| `approved` \| `available` \| `denied` | — | Request status filter (multi-select) |
|
||||||
|
| `sort` | `requestedDate_desc` \| `requestedDate_asc` \| `title_asc` \| `title_desc` | `requestedDate_desc` | Sort mode |
|
||||||
|
| `search` | string | — | Case-insensitive title substring |
|
||||||
|
| `showAll` | `'true'` \| `'false'` | `'false'` | Admin only: show all users' requests |
|
||||||
|
|
||||||
|
#### Status priority
|
||||||
|
|
||||||
|
The same `getRequestStatus()` function runs on both server and client:
|
||||||
|
|
||||||
|
1. `available` — if `available === true`
|
||||||
|
2. `denied` — if `denied === true`
|
||||||
|
3. `approved` — if `approved === true`
|
||||||
|
4. `pending` — if `requested === true`
|
||||||
|
5. `unknown` — fallback
|
||||||
|
|
||||||
|
#### Persistence
|
||||||
|
|
||||||
|
Filter and sort preferences are persisted in `localStorage` under the following keys:
|
||||||
|
|
||||||
|
| Key | Content |
|
||||||
|
|-----|---------|
|
||||||
|
| `sofarr-request-types` | `['movie', 'tv']` or subset |
|
||||||
|
| `sofarr-request-statuses` | `['pending', 'approved', 'available', 'denied']` or subset |
|
||||||
|
| `sofarr-request-sort` | `requestedDate_desc`, `requestedDate_asc`, `title_asc`, `title_desc` |
|
||||||
|
| `sofarr-request-search` | Free-text query string |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Caching and Smart Polling
|
## 6. Caching and Smart Polling
|
||||||
@@ -562,6 +755,7 @@ class MemoryCache {
|
|||||||
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
|
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
|
||||||
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
|
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
|
||||||
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
|
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
|
||||||
|
| `poll:ombi` | `{ movie: [], tv: [] }` | `POLL_INTERVAL × 3` |
|
||||||
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
||||||
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
||||||
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
|
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
|
||||||
@@ -616,8 +810,8 @@ See [Section 3.1](#31-pluggable-download-client-architecture-pdca) for full deta
|
|||||||
|
|
||||||
```
|
```
|
||||||
DownloadClient (abstract — server/clients/DownloadClient.js)
|
DownloadClient (abstract — server/clients/DownloadClient.js)
|
||||||
├── SABnzbdClient.js — Usenet; REST; API key auth
|
├── SABnzbdClient.js — Usenet; REST; API key auth; fixed global-speed assignment
|
||||||
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth
|
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth; full-sync corruption fix
|
||||||
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
|
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
|
||||||
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
|
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
|
||||||
```
|
```
|
||||||
@@ -626,7 +820,9 @@ DownloadClient (abstract — server/clients/DownloadClient.js)
|
|||||||
|
|
||||||
### 7.2 Queue & History Processing
|
### 7.2 Queue & History Processing
|
||||||
|
|
||||||
**`server/utils/historyFetcher.js`** fetches history records from all Sonarr/Radarr instances for a configurable date window. Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`.
|
**`server/utils/historyFetcher.js`** fetches history records from all Sonarr/Radarr instances for a configurable date window. Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`), `invalidateHistoryCache`, and `onHistoryUpdate` / `emitHistoryUpdate` for SSE staging.
|
||||||
|
|
||||||
|
**Staged loading** — the fetcher returns up to `INITIAL_PAGE_SIZE` (100) records immediately from the cache or a quick fetch. If fewer than `MAX_TOTAL_RECORDS` (1,000) are present, a background fetch of up to `MAX_PAGES` (10) is triggered automatically. As the background fetch completes, `emitHistoryUpdate()` notifies all registered subscribers, which causes the SSE layer to push a `history-update` frame to every connected browser. The frontend (`client/src/ui/history.js`) listens for these events and re-renders the "Recently Completed" tab incrementally.
|
||||||
|
|
||||||
**`server/routes/history.js`** (`GET /api/history/recent`) returns recently completed (imported or failed) downloads filtered for the authenticated user. Supports `?days=N` (default `RECENT_COMPLETED_DAYS`, capped at 90) and `?showAll=true` for admins. Results are sorted newest first.
|
**`server/routes/history.js`** (`GET /api/history/recent`) returns recently completed (imported or failed) downloads filtered for the authenticated user. Supports `?days=N` (default `RECENT_COMPLETED_DAYS`, capped at 90) and `?showAll=true` for admins. Results are sorted newest first.
|
||||||
|
|
||||||
@@ -634,12 +830,34 @@ DownloadClient (abstract — server/clients/DownloadClient.js)
|
|||||||
|
|
||||||
### 7.3 Dashboard & Frontend
|
### 7.3 Dashboard & Frontend
|
||||||
|
|
||||||
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `public/app.js`, styled by `public/style.css`, and structured by `public/index.html`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
|
The frontend is a **vanilla JavaScript SPA** built from ES modules in `client/src/` and bundled by **Vite** into `public/app.js`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
|
||||||
|
|
||||||
- **Light** — Purple gradient header, white cards
|
- **Light** — Purple gradient header, white cards
|
||||||
- **Dark** — Dark surfaces, muted accents
|
- **Dark** — Dark surfaces, muted accents
|
||||||
- **Mono** — Monochrome, minimal colour
|
- **Mono** — Monochrome, minimal colour
|
||||||
|
|
||||||
|
#### Module structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client/src/
|
||||||
|
├── main.js Bootstrap: DOMContentLoaded → init theme, auth check
|
||||||
|
├── state.js Global reactive state object
|
||||||
|
├── api.js All HTTP fetch wrappers (+ CSRF handling)
|
||||||
|
├── sse.js EventSource lifecycle, reconnect, heartbeat
|
||||||
|
├── ui/
|
||||||
|
│ ├── auth.js Login/logout form handlers
|
||||||
|
│ ├── downloads.js Card rendering, create/update helpers, client-logo helpers
|
||||||
|
│ ├── filters.js Download-client multi-select filter
|
||||||
|
│ ├── history.js History tab: fetch, render, ignoreAvailable toggle
|
||||||
|
│ ├── statusPanel.js Admin status panel (server, polling, cache, webhooks)
|
||||||
|
│ ├── tabs.js Tab navigation (data-tab attributes)
|
||||||
|
│ ├── theme.js Light/Dark/Mono theme switcher
|
||||||
|
│ └── webhooks.js One-click Sonarr/Radarr webhook configuration
|
||||||
|
└── utils/
|
||||||
|
├── format.js Size, speed, duration, percentage formatters
|
||||||
|
└── storage.js localStorage wrappers with JSON parsing
|
||||||
|
```
|
||||||
|
|
||||||
#### UI state machine
|
#### UI state machine
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -675,28 +893,106 @@ stateDiagram-v2
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Key frontend functions
|
#### Key frontend modules
|
||||||
|
|
||||||
| Function | Purpose |
|
| Module / Function | Purpose |
|
||||||
|----------|---------|
|
|-------------------|---------|
|
||||||
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
| `auth.js` | `checkAuthentication()`, `handleLogin()`, `handleLogout()` |
|
||||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
| `sse.js` | `startSSE()`, `stopSSE()` — EventSource lifecycle and auto-reconnect |
|
||||||
| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data |
|
| `downloads.js` | `renderDownloads()`, `createDownloadCard()`, `updateDownloadCard()` — diff-based DOM; client-logo and tag-badge helpers deduplicated |
|
||||||
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
|
| `filters.js` | `initDownloadClientFilter()` — multi-select dropdown, Select/Deselect All, localStorage persistence |
|
||||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
| `history.js` | `loadHistory()`, `renderHistory()` — filter by `ignoreAvailable`, render cards |
|
||||||
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
|
| `statusPanel.js` | `toggleStatusPanel()`, `renderStatusPanel()` — admin server/polling/cache/webhook status |
|
||||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
| `theme.js` | `initThemeSwitcher()` — Light / Dark / Mono theme support |
|
||||||
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
|
| `webhooks.js` | One-click Sonarr/Radarr webhook configuration via proxy API |
|
||||||
| `toggleStatusPanel()` | Show/hide admin status panel |
|
| `format.js` | Size, speed, duration, percentage formatters (24 unit tests) |
|
||||||
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
|
| `storage.js` | localStorage wrappers with JSON parsing and error handling |
|
||||||
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
|
||||||
| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` |
|
|
||||||
| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards |
|
|
||||||
|
|
||||||
#### Tag badge rendering
|
#### CSP compliance
|
||||||
|
|
||||||
- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
|
All UI modules use CSS class toggling (`.hidden`) instead of inline `style.display` to comply with the strict Content-Security-Policy enforced by Helmet.
|
||||||
- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
|
|
||||||
|
#### Download Client Filter
|
||||||
|
|
||||||
|
The Active Downloads tab includes a multi-select dropdown filter that allows users to:
|
||||||
|
- View all download clients with their type displayed as "Client Name (type)"
|
||||||
|
- Select multiple clients to filter the downloads list
|
||||||
|
- Use "Select All" / "Deselect All" buttons for bulk operations
|
||||||
|
- Persist selection across sessions via localStorage
|
||||||
|
|
||||||
|
Related functions in `filters.js`:
|
||||||
|
- `initDownloadClientFilter()` — Sets up dropdown toggle, click-outside handler, Select/Deselect All buttons
|
||||||
|
- `updateDownloadClientFilter()` — Populates checkbox list with client name + type badges
|
||||||
|
- `toggleClientSelection()` — Updates selection array and localStorage
|
||||||
|
- `updateSelectedCountDisplay()` — Updates button text to show "All clients" / "1 selected" / "N selected"
|
||||||
|
|
||||||
|
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
|
||||||
|
|
||||||
|
### 7.4 API Documentation System
|
||||||
|
|
||||||
|
sofarr exposes interactive API documentation via **Swagger UI** at `/api/swagger`, using a hybrid documentation model that balances maintainability with consistency.
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
server/openapi.yaml Central spec: base metadata, security schemes, reusable schemas
|
||||||
|
│
|
||||||
|
│ merge at runtime (swagger-jsdoc)
|
||||||
|
▼
|
||||||
|
server/routes/*.js JSDoc @openapi comments per endpoint
|
||||||
|
│
|
||||||
|
│ serve (swagger-ui-express)
|
||||||
|
▼
|
||||||
|
GET /api/swagger Swagger UI HTML (public, auth banner)
|
||||||
|
GET /api/swagger.json Merged OpenAPI 3.1 spec JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Central OpenAPI Specification (`server/openapi.yaml`)
|
||||||
|
|
||||||
|
The YAML file defines:
|
||||||
|
- **Base metadata** — `openapi: 3.1.0`, info, server URL, contact
|
||||||
|
- **Security schemes** — `CookieAuth` (`emby_user` cookie), `CsrfToken` (`X-CSRF-Token` header)
|
||||||
|
- **Component schemas** — `NormalizedDownload`, `DashboardPayload`, `ErrorResponse`, `BlocklistSearchRequest`, `WebhookPayload`, `HistoryItem`, `StatusResponse`
|
||||||
|
- **Path placeholders** — stub entries for every endpoint so JSDoc comments have a merge target
|
||||||
|
|
||||||
|
#### JSDoc `@openapi` Comments
|
||||||
|
|
||||||
|
Every route handler file contains JSDoc comments above each Express route definition:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/auth/login:
|
||||||
|
* post:
|
||||||
|
* tags: [Authentication]
|
||||||
|
* summary: Authenticate with Emby
|
||||||
|
* ...
|
||||||
|
*/
|
||||||
|
router.post('/login', ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`swagger-jsdoc` scans `server/routes/**/*.js` and merges the YAML from these comments into the central spec at runtime.
|
||||||
|
|
||||||
|
#### Machine-Usable Extensions
|
||||||
|
|
||||||
|
For AI agents and automated tooling, every endpoint includes:
|
||||||
|
- **`x-code-samples`** — cURL, JavaScript fetch, and TypeScript examples
|
||||||
|
- **`x-integration-notes`** — Human-readable integration guidance embedded in descriptions
|
||||||
|
|
||||||
|
#### Coverage Validation
|
||||||
|
|
||||||
|
`tests/integration/swagger-coverage.test.js` (22 tests) validates:
|
||||||
|
- The YAML spec parses without errors
|
||||||
|
- Every Express route appears in the merged spec
|
||||||
|
- All examples are valid JSON
|
||||||
|
- Security schemes are correctly referenced (`CookieAuth`, `CsrfToken`)
|
||||||
|
- The Swagger UI endpoint returns `200`
|
||||||
|
|
||||||
|
#### CI/CD Integration
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` includes a "Swagger Validation & Coverage" job that:
|
||||||
|
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||||
|
- Runs the coverage test suite on every push
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -714,10 +1010,14 @@ sofarr/
|
|||||||
│ │ ├── TransmissionClient.js
|
│ │ ├── TransmissionClient.js
|
||||||
│ │ ├── RTorrentClient.js
|
│ │ ├── RTorrentClient.js
|
||||||
│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever
|
│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever
|
||||||
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
│ │ ├── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
||||||
|
│ │ ├── ArrRetriever.js PALDRA — Abstract base class for *arr retrievers
|
||||||
|
│ │ ├── OmbiClient.js Low-level Ombi API client
|
||||||
|
│ │ └── OmbiRetriever.js PALDRA — Ombi retriever with caching
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
|
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
|
||||||
│ │ ├── dashboard.js SSE /stream, /user-downloads, /user-summary, /status, /cover-art, /blocklist-search
|
│ │ ├── dashboard.js SSE /stream, /user-downloads, /blocklist-search
|
||||||
|
│ │ ├── status.js GET /api/status — admin server/polling/webhook status
|
||||||
│ │ ├── history.js GET /api/history/recent
|
│ │ ├── history.js GET /api/history/recent
|
||||||
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
|
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
|
||||||
│ │ ├── sonarr.js Sonarr API proxy + webhook management
|
│ │ ├── sonarr.js Sonarr API proxy + webhook management
|
||||||
@@ -727,6 +1027,12 @@ sofarr/
|
|||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
|
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
|
||||||
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
|
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
|
||||||
|
│ ├── services/ Download matching & assembly services
|
||||||
|
│ │ ├── DownloadBuilder.js Orchestrator: cache snapshot → matched downloads
|
||||||
|
│ │ ├── DownloadMatcher.js Match SABnzbd/qBittorrent to *arr records
|
||||||
|
│ │ ├── DownloadAssembler.js Pure helpers: cover art, links, episodes, blocklist
|
||||||
|
│ │ ├── TagMatcher.js Tag extraction, sanitisation, user matching
|
||||||
|
│ │ └── WebhookStatus.js Webhook configuration check + metrics aggregation
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
|
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
|
||||||
│ ├── cache.js MemoryCache + webhook metrics helpers
|
│ ├── cache.js MemoryCache + webhook metrics helpers
|
||||||
@@ -738,15 +1044,37 @@ sofarr/
|
|||||||
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
|
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
|
||||||
│ ├── sanitizeError.js Secret redaction from errors/logs
|
│ ├── sanitizeError.js Secret redaction from errors/logs
|
||||||
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
|
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
|
||||||
├── public/ Static SPA (served by Express)
|
├── client/ Frontend source (vanilla ES modules)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js Bootstrap entry point
|
||||||
|
│ │ ├── state.js Global reactive state
|
||||||
|
│ │ ├── api.js HTTP fetch wrappers
|
||||||
|
│ │ ├── sse.js EventSource management
|
||||||
|
│ │ ├── ui/
|
||||||
|
│ │ │ ├── auth.js
|
||||||
|
│ │ │ ├── downloads.js
|
||||||
|
│ │ │ ├── filters.js
|
||||||
|
│ │ │ ├── history.js
|
||||||
|
│ │ │ ├── statusPanel.js
|
||||||
|
│ │ │ ├── tabs.js
|
||||||
|
│ │ │ ├── theme.js
|
||||||
|
│ │ │ └── webhooks.js
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ ├── format.js
|
||||||
|
│ │ └── storage.js
|
||||||
|
│ ├── index.html Development HTML shell
|
||||||
|
│ ├── package.json Frontend dev dependencies (vite)
|
||||||
|
│ └── vite.config.js Build config → ../public/app.js
|
||||||
|
├── public/ Static SPA assets (served by Express)
|
||||||
│ ├── index.html HTML shell: splash, login, dashboard
|
│ ├── index.html HTML shell: splash, login, dashboard
|
||||||
│ ├── app.js All frontend logic
|
│ ├── app.js Bundled frontend (Vite build output)
|
||||||
│ ├── style.css Themes, layout, responsive design
|
│ ├── style.css Themes, layout, responsive design
|
||||||
│ ├── favicon.ico / *.png Favicons
|
│ ├── favicon.ico / *.png Favicons
|
||||||
│ └── images/ Logo / splash screen assets
|
│ └── images/ Logo / splash screen assets
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── README.md Testing approach and coverage targets
|
│ ├── README.md Testing approach and coverage targets
|
||||||
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
|
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
|
||||||
|
│ ├── frontend/ Vitest + jsdom unit tests for client/src
|
||||||
│ ├── unit/ Pure unit tests (no HTTP)
|
│ ├── unit/ Pure unit tests (no HTTP)
|
||||||
│ └── integration/ Supertest + nock integration tests
|
│ └── integration/ Supertest + nock integration tests
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
@@ -755,7 +1083,7 @@ sofarr/
|
|||||||
│ ├── create-release.yml Release tagging workflow
|
│ ├── create-release.yml Release tagging workflow
|
||||||
│ ├── docs-check.yml Markdown lint + Mermaid validation
|
│ ├── docs-check.yml Markdown lint + Mermaid validation
|
||||||
│ └── licence-check.yml Production dependency licence check
|
│ └── licence-check.yml Production dependency licence check
|
||||||
├── Dockerfile Multi-stage production image (node:22-alpine)
|
├── Dockerfile Multi-stage production image (node:22-alpine) — includes Vite client build stage
|
||||||
├── docker-compose.yaml Example compose deployment
|
├── docker-compose.yaml Example compose deployment
|
||||||
├── vitest.config.js Test runner configuration with per-file coverage thresholds
|
├── vitest.config.js Test runner configuration with per-file coverage thresholds
|
||||||
├── package.json Dependencies and scripts
|
├── package.json Dependencies and scripts
|
||||||
@@ -887,9 +1215,10 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| Framework | Express 4.x | HTTP server, routing, middleware |
|
| Framework | Express 4.x | HTTP server, routing, middleware |
|
||||||
| HTTP client | axios 1.x | External API communication |
|
| HTTP client | axios 1.x | External API communication |
|
||||||
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
|
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
|
||||||
| Frontend | Vanilla JS + CSS | SPA, no build step required |
|
| Frontend | Vanilla JS + CSS | SPA; Vite bundles ES modules from `client/src/` into `public/app.js` |
|
||||||
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
|
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
|
||||||
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
|
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
|
||||||
|
| Request Management | Ombi (optional) | External ID matching and request linking |
|
||||||
|
|
||||||
### Security Middleware
|
### Security Middleware
|
||||||
|
|
||||||
@@ -899,6 +1228,15 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| `express-rate-limit` | 7.x | General, login, and webhook rate limiters |
|
| `express-rate-limit` | 7.x | General, login, and webhook rate limiters |
|
||||||
| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `swagger-ui-express` | 5.x | Serve interactive Swagger UI at `/api/swagger` |
|
||||||
|
| `swagger-jsdoc` | 6.x | Merge JSDoc `@openapi` comments with central YAML spec |
|
||||||
|
| `yamljs` | 0.3.x | Parse `server/openapi.yaml` at runtime for swagger-jsdoc |
|
||||||
|
| `@stoplight/spectral-cli` | 6.x (dev) | Lint OpenAPI spec for correctness in CI |
|
||||||
|
|
||||||
### Auth and Session
|
### Auth and Session
|
||||||
|
|
||||||
| Component | Technology | Details |
|
| Component | Technology | Details |
|
||||||
@@ -915,12 +1253,13 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
|
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
|
||||||
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
|
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
|
||||||
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
|
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
|
||||||
|
| `jsdom` | 24.x | Browser-like DOM environment for frontend unit tests |
|
||||||
|
|
||||||
### CI/CD
|
### CI/CD
|
||||||
|
|
||||||
| Workflow file | Trigger | Purpose |
|
| Workflow file | Trigger | Purpose |
|
||||||
|---------------|---------|---------|
|
|---------------|---------|---------|
|
||||||
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite with V8 coverage |
|
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite (unit, integration, frontend) with V8 coverage |
|
||||||
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
|
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
|
||||||
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
|
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
|
||||||
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
|
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
|
||||||
|
|||||||
@@ -4,6 +4,289 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
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).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.10] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.9] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.8] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.7] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.6] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||||
|
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.5] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.4] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook registration in production** — Fixed a bug where `/api/ombi/webhook/enable` and other `/api/ombi/*` endpoints returned `404 (Not Found)` in production. The core Express app factory (`server/app.js`) registered the router, but the production server entry point (`server/index.js`) duplicated Express setup instead of utilizing the factory, omitting the `ombiRoutes` registration entirely. Re-registered the router in `server/index.js`, resolves Gitea Issue [#40](https://git.i3omb.com/Gandalf/sofarr/issues/40).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.3] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Download client dropdown filter icons and type badges** — Refactored the download client selector dropdown in `client/src/ui/filters.js` to render utilizing the design system classes `.download-client-option`, `.download-client-icon`, `.download-client-option-label`, and `.download-client-type`. Options now display the client's brand SVG logo (e.g., `sabnzbd.svg`, `qbittorrent.svg` loaded from `/images/clients/`) next to the configured display name, along with a clean pill badge indicating the client type. This resolves visual ambiguity when multiple download clients share the same name (such as `"i3omb"` for SABnzbd and qBittorrent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-05-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook configuration check** — Ombi webhook status now correctly checks for `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` environment variables. The webhook displays as disabled when either is missing, matching the existing *ARR webhook behavior.
|
||||||
|
- **Common webhook config endpoint** — Added `GET /api/webhook/config` endpoint that returns whether both `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` are configured, along with a list of missing items. Used by the client to determine webhook enablement status across all webhook types.
|
||||||
|
- **Client-side webhook status** — Updated `fetchWebhookStatus()` to call `/api/webhook/config` and use the result to determine if Sonarr and Radarr webhooks are enabled. Webhook status now depends on both the service-specific webhook being configured AND the Sofarr webhook config being valid.
|
||||||
|
- **Webhook config endpoint authentication** — Added `requireAuth` middleware to `GET /api/webhook/config` to enforce authentication, matching the OpenAPI contract and preventing unauthenticated information disclosure.
|
||||||
|
- **Ombi webhook enable/test config validation** — `POST /api/ombi/webhook/enable` and `POST /api/ombi/webhook/test` now validate `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` before making outbound Ombi API calls, returning `400` with descriptive errors when either is missing. Previously these routes would construct malformed URLs (`undefined/api/webhook/ombi`) if the environment variables were unset.
|
||||||
|
- **Test environment cleanup** — `tests/integration/webhook.test.js` `afterEach` now cleans up `EMBY_URL`, `SOFARR_BASE_URL`, `SONARR_INSTANCES`, and `RADARR_INSTANCES` to ensure hermetic test isolation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Ombi PALDRA Integration
|
||||||
|
|
||||||
|
- **OmbiRetriever** — New PALDRA-compliant retriever extending `ArrRetriever`, registered in `arrRetrieverRegistry` alongside Sonarr/Radarr. Manages Ombi request data with 5-minute TTL cache and lookup maps by TMDB/TVDB/IMDB IDs.
|
||||||
|
- **OmbiClient** — Low-level Ombi API client for HTTP communication (movie/TV requests, search by external ID, connection test).
|
||||||
|
- **`getOmbiInstances()`** — New config function in `server/utils/config.js` following the existing multi-instance JSON array pattern; supports both `OMBI_INSTANCES` and legacy `OMBI_URL`/`OMBI_API_KEY` formats.
|
||||||
|
- **PALDRA registry Ombi methods** — `getOmbiRetrievers()`, `getOmbiRequests()`, `getOmbiRequestsByType()`, `findOmbiRequest()` added to `arrRetrieverRegistry`.
|
||||||
|
- **External ID matching** — Downloads are matched to Ombi requests using TVDB ID → TMDB ID (TV) and TMDB ID → IMDB ID (movies); falls back to an Ombi search link when no request exists.
|
||||||
|
- **`getOmbiLink()` / `getOmbiSearchLink()`** — New helpers in `DownloadAssembler.js` following the `getSonarrLink`/`getRadarrLink` pattern.
|
||||||
|
- **Service icon layout** — Downloads and history cards now render inline SVG icons (Ombi for all users; Sonarr/Radarr for admins) instead of linked series/movie names. CSS `.service-icons-container` and `.service-icon` classes added to `public/style.css`.
|
||||||
|
- **OpenAPI** — `NormalizedDownload` schema extended with `ombiLink`, `ombiRequestId`, `ombiTooltip` nullable string properties; `Ombi` tag added to the spec.
|
||||||
|
- **`OMBI_INSTANCES` / `OMBI_URL` / `OMBI_API_KEY`** — New environment variables documented in `.env.sample`, `README.md`, `ARCHITECTURE.md`, and `SECURITY.md`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`DownloadMatcher.js`** — `matchSabSlots`, `matchSabHistory`, and `matchTorrents` are now `async`; each matched download object is enriched with `ombiLink`, `ombiRequestId`, and `ombiTooltip` via `addOmbiMatching()`.
|
||||||
|
- **`DownloadBuilder.js`** — `buildUserDownloads` accepts `ombiRetriever` and `ombiBaseUrl` in its options object and passes them through to matching context.
|
||||||
|
- **Dashboard routes** — Both the REST endpoint and SSE stream now resolve the Ombi retriever from the PALDRA registry and include it in the download-building context.
|
||||||
|
- **`arrRetrievers.js`** — PALDRA registry now imports `OmbiRetriever`, maps `'ombi'` in `retrieverClasses`, and initialises instances from `getOmbiInstances()`.
|
||||||
|
- **`ARCHITECTURE.md`** — PALDRA section updated with OmbiRetriever description, registry API additions, and directory-structure entries. Technology stack table updated.
|
||||||
|
- **`SECURITY.md`** — Threat model extended with Ombi API key exposure and rate-limit exhaustion mitigations.
|
||||||
|
- **`README.md`** — Prerequisites and new *Ombi Integration (Optional)* configuration section added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### RAML 1.0 Package Generation
|
||||||
|
|
||||||
|
- **Automated RAML generation in CI/CD** — Added RAML 1.0 package generation to the existing `swagger` job in `.gitea/workflows/ci.yml`. The pipeline now generates a downloadable `raml-package` artifact on every push and PR, available from the Actions run page with 14-day retention.
|
||||||
|
- **RAML generation scripts** — Created three new scripts in `scripts/`:
|
||||||
|
- `generate-openapi.js` — Bootstraps the Express app in test mode, fetches the merged OpenAPI 3.1 spec from `/api/swagger.json`, and exports it to disk.
|
||||||
|
- `downgrade-openapi.js` — Downgrades OpenAPI 3.1 to 3.0 for RAML compatibility (existing RAML converters don't support 3.1).
|
||||||
|
- `simple-raml-converter.js` — Converts OpenAPI 3.0 to RAML 1.0 format using a custom converter (modern RAML converters are unmaintained).
|
||||||
|
- `package-raml.js` — Creates a versioned tar.gz archive containing the RAML spec, original OpenAPI spec, version metadata, and README.
|
||||||
|
- **RAML artifact structure** — Each artifact includes:
|
||||||
|
- `api.raml` — RAML 1.0 specification
|
||||||
|
- `openapi-merged.json` — Original merged OpenAPI 3.1.0 spec (for reference)
|
||||||
|
- `version.json` — Metadata (version, commit SHA, timestamp, tool used)
|
||||||
|
- `README.md` — Origin, conversion details, known limitations, and verification steps
|
||||||
|
- **npm scripts** — Added three new scripts to `package.json`:
|
||||||
|
- `generate:openapi` — Generates merged OpenAPI spec
|
||||||
|
- `generate:raml` — Downgrades and converts to RAML
|
||||||
|
- `package:raml` — Packages the RAML artifact
|
||||||
|
- **Spectral ruleset** — Created minimal `.spectral.yml` ruleset to make the existing Spectral lint step functional (previously failing silently due to missing ruleset).
|
||||||
|
- **OpenAPI JSON endpoint** — Added `/api/swagger.json` endpoint to `server/app.js` to serve the raw merged OpenAPI spec as JSON, enabling programmatic access.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Dependencies added** — `archiver` (^7.0.1) for creating tar.gz archives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Swagger UI & OpenAPI 3.1 Documentation
|
||||||
|
|
||||||
|
- **Swagger UI at `/api/swagger`** — Interactive API documentation served via `swagger-ui-express`; publicly accessible with a custom authentication banner (`public/swagger-auth-banner.js`) that explains the cookie-based + CSRF-token authentication flow for testing endpoints directly in the browser.
|
||||||
|
- **OpenAPI 3.1 specification** — Central `server/openapi.yaml` file containing base metadata, security schemes (`CookieAuth`, `CsrfToken`), and reusable component schemas:
|
||||||
|
- `NormalizedDownload` — standardised download object returned by all PDCA clients
|
||||||
|
- `DashboardPayload` — SSE payload shape (`{ user, isAdmin, downloads, downloadClients }`)
|
||||||
|
- `ErrorResponse` — standard error envelope with redacted details
|
||||||
|
- `BlocklistSearchRequest` — payload for the admin blocklist-and-search operation
|
||||||
|
- `WebhookPayload` — Sonarr/Radarr webhook event structure
|
||||||
|
- `HistoryItem` — deduplicated history record with upgrade-availability flag
|
||||||
|
- `StatusResponse` — server metrics, polling timings, cache stats, and webhook metrics
|
||||||
|
- **Hybrid documentation approach** — Per-endpoint details are documented directly in route handler files (`server/routes/*.js`) using JSDoc `@openapi` comments. `swagger-jsdoc` merges these comments with the central YAML at runtime, keeping documentation close to the code while maintaining shared schemas in one place.
|
||||||
|
- **Comprehensive endpoint coverage** — All implemented endpoints are documented:
|
||||||
|
- Authentication: `POST /api/auth/login`, `GET /api/auth/me`, `GET /api/auth/csrf`, `POST /api/auth/logout`
|
||||||
|
- Dashboard: `GET /api/dashboard/stream` (SSE), `GET /api/dashboard/user-downloads` (deprecated), `GET /api/dashboard/cover-art`, `POST /api/dashboard/blocklist-search`
|
||||||
|
- Status: `GET /api/status`
|
||||||
|
- History: `GET /api/history/recent`
|
||||||
|
- Webhooks: `POST /api/webhook/sonarr`, `POST /api/webhook/radarr`
|
||||||
|
- Proxy routes: Sonarr, Radarr, SABnzbd, and Emby authenticated proxies
|
||||||
|
- Public health: `GET /health`, `GET /ready`
|
||||||
|
- **Machine-usable extensions** — Every documented endpoint includes:
|
||||||
|
- `x-code-samples` with cURL, JavaScript fetch, and TypeScript examples
|
||||||
|
- `x-integration-notes` section in descriptions for AI agents and automated tooling
|
||||||
|
- Realistic request/response examples and full JSON Schema definitions
|
||||||
|
- **Coverage validation test suite** — `tests/integration/swagger-coverage.test.js` (22 tests) validates that:
|
||||||
|
- The OpenAPI spec loads without YAML parse errors
|
||||||
|
- Every Express route appears in the merged spec
|
||||||
|
- All schema and response examples are valid JSON
|
||||||
|
- Required security schemes (`CookieAuth`, `CsrfToken`) are defined and referenced correctly
|
||||||
|
- The Swagger UI HTML endpoint (`GET /api/swagger`) returns `200`
|
||||||
|
- **CI/CD validation job** — Added "Swagger Validation & Coverage" job in `.gitea/workflows/ci.yml` that runs on every push:
|
||||||
|
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||||
|
- Runs `npm test -- tests/integration/swagger-coverage.test.js` to verify coverage
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Dependencies added** — `swagger-ui-express` (^5.0.1), `swagger-jsdoc` (^6.2.8), `yamljs` (^0.3.0), and `@stoplight/spectral-cli` (^6.16.0 dev dependency) for OpenAPI generation, UI serving, and spec linting.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Swagger UI public access** — The Swagger UI endpoint (`/api/swagger`) is publicly accessible by design for convenience. All documented API endpoints still enforce authentication (`emby_user` cookie) and CSRF protection (`X-CSRF-Token` header for mutations) as before. The authentication banner in the UI explicitly instructs users to log in via `POST /api/auth/login` first before testing protected endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-05-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Staged history loading with SSE push** — history data from Sonarr/Radarr is now fetched in stages; `history-update` SSE events push incremental results to the dashboard so the "Recently Completed" tab populates without waiting for the full fetch.
|
||||||
|
- **Frontend unit tests** — added Vitest + jsdom test suite covering `client/src/` modules (formatters, storage, state).
|
||||||
|
- **Comprehensive tests for staged history loading** — backend tests verify the new background-fetch behaviour, cache TTL handling, and SSE emission.
|
||||||
|
- **Integration test coverage** — new integration and unit tests for `dashboard`, `emby`, `sonarr`, `radarr`, and `sabnzbd` routes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Technical-debt remediation — service extraction** — extracted matching and assembly logic from the monolithic `dashboard.js` (~1,360 lines → ~284 lines) into dedicated, testable services:
|
||||||
|
- `DownloadMatcher.js` — SABnzbd slot / torrent → *arr record matching
|
||||||
|
- `DownloadAssembler.js` — cover-art, episode, link, blocklist-eligibility helpers
|
||||||
|
- `DownloadBuilder.js` — orchestrator that reads the cache snapshot and builds the final user-facing download list
|
||||||
|
- `TagMatcher.js` — tag extraction, sanitisation, and user-ownership matching
|
||||||
|
- `WebhookStatus.js` — webhook configuration status aggregation
|
||||||
|
- **Frontend architecture** — migrated from a monolithic `public/app.js` to vanilla ES modules under `client/src/`, bundled by Vite into `public/app.js`.
|
||||||
|
- **History pagination** — replaced custom date-based cursor pagination with the retriever's built-in pagination (`pageSize=100`, up to 10 pages / 1,000 records total). Eliminates the 40-second response-time regression seen with large histories.
|
||||||
|
- **Status endpoint path** — admin status route moved from `/api/status/status` to `/api/status` for consistency with the router mount point.
|
||||||
|
- **Background-fetch safety** — the poller no longer overwrites the cache with empty data when a background history fetch fails or returns no records.
|
||||||
|
- **SABnzbd progress calculation** — progress is now computed from `slot.mb` and `slot.mbleft` / `slot.mbmissing` because the SABnzbd queue API does not expose a `percentage` field.
|
||||||
|
- **Speed formatting consistency** — `updateDownloadCard()` now calls `formatSpeed()` so all speed values display with uniform units (B/s, KB/s, MB/s, GB/s).
|
||||||
|
- **Status-panel error handling** — the panel now surfaces error messages (e.g. `403` for non-admin users) instead of showing a blank box.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CSRF token reference errors** — fixed `ReferenceError` bugs in `api.js`, `auth.js`, and the blocklist handler where `csrfToken` and `currentUser` were accessed as bare variables instead of via the shared `state` object.
|
||||||
|
- **Logout button** — fixed undefined variable references in `handleLogoutClick` that prevented the logout flow from completing.
|
||||||
|
- **Missing progress bar for SABnzbd** — SABnzbd downloads now render a correct progress bar instead of showing `undefined%`.
|
||||||
|
- **Status route 404** — corrected the Express router mount so `GET /api/status` responds instead of returning HTML 404.
|
||||||
|
- **Status button DOM ID** — fixed element-ID mismatch (`status-btn` vs `status-toggle`) that prevented the admin status button from toggling the panel.
|
||||||
|
- **Tab selection** — fixed tab switching to use `data-tab` attributes after the DOM IDs were removed during the ES-module migration.
|
||||||
|
- **CSP violations and `ignoreAvailable` reference error** — resolved frontend CSP compliance issues and a missing-variable error in the history tab.
|
||||||
|
- **Docker client-build stage** — removed `client/` from `.dockerignore` so the multi-stage Dockerfile can run the Vite build during image creation.
|
||||||
|
- **Unmatched torrent exclusion** — torrents that cannot be matched to a Sonarr/Radarr record are now correctly omitted from the download display.
|
||||||
|
- **Blocklist button CSRF** — fixed a `ReferenceError` that occurred when a non-admin user clicked the blocklist button.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **Unmatched torrents are no longer displayed** — torrents that do not match a Sonarr/Radarr queue or history record are excluded from the dashboard. Users who previously saw direct torrent-client downloads will no longer see them unless the *arr services track the item.
|
||||||
|
- **Frontend build process changed** — the monolithic `public/app.js` source file has been replaced by a Vite build from `client/src/`. Any custom deployment scripts that copy or modify `public/app.js` directly must be updated to run `npm run build` (or use the provided Dockerfile, which already does this).
|
||||||
|
- **Status API endpoint path changed** — the admin status endpoint moved from `/api/status/status` to `/api/status`. External integrations, monitoring checks, or scripts hitting the old path will receive `404`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.5] - 2026-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
|
||||||
|
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
|
||||||
|
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
|
||||||
|
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
|
||||||
|
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
|
||||||
|
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
|
||||||
|
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.4] - 2026-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
|
||||||
|
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
|
||||||
|
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
|
||||||
|
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
|
||||||
|
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.3] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
|
||||||
|
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
|
||||||
|
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
|
||||||
|
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.2] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.5.1] - 2026-05-19
|
## [1.5.1] - 2026-05-19
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
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)
|
# 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 application source owned by root (read-only at runtime)
|
||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
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 ./
|
COPY --chown=root:root package.json ./
|
||||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
# 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.
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**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!
|
**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.4.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
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
|
## What It Does
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
|||||||
- Sonarr (optional, for TV tracking)
|
- Sonarr (optional, for TV tracking)
|
||||||
- Radarr (optional, for movie tracking)
|
- Radarr (optional, for movie tracking)
|
||||||
- Emby (for user authentication)
|
- Emby (for user authentication)
|
||||||
|
- Ombi (optional, for request management integration)
|
||||||
|
|
||||||
## Docker Deployment (Recommended)
|
## Docker Deployment (Recommended)
|
||||||
|
|
||||||
@@ -305,6 +306,31 @@ RTORRENT_USERNAME=rtorrent
|
|||||||
RTORRENT_PASSWORD=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
|
## Setting Up User Tags
|
||||||
|
|
||||||
To see your downloads, you need to tag your media in Sonarr/Radarr:
|
To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||||
@@ -353,6 +379,49 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
- **Peers** - Number of peers
|
- **Peers** - Number of peers
|
||||||
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
- **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
|
## API Endpoints
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -375,8 +444,10 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||||
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
||||||
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
||||||
|
- `POST /api/webhook/ombi` — receive Ombi webhook events
|
||||||
|
|
||||||
### Webhook Management (requires auth + CSRF)
|
### Webhook Management (requires auth + CSRF)
|
||||||
|
- `GET /api/webhook/config` — get webhook configuration status
|
||||||
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
|
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
|
||||||
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
|
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
|
||||||
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
|
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
|
||||||
@@ -385,6 +456,12 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
|
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
|
||||||
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
|
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
|
||||||
- `POST /api/radarr/webhook/test` — trigger a Radarr 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)
|
### Service APIs (proxy to your services)
|
||||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||||
@@ -429,7 +506,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
|||||||
npm run test:ui # interactive Vitest UI
|
npm run test:ui # interactive Vitest UI
|
||||||
```
|
```
|
||||||
|
|
||||||
290 tests across 18 test 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, and webhook metrics integration. 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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| 1.4.x | ✅ Yes |
|
| 1.7.x | ✅ Yes |
|
||||||
| 1.3.x | ✅ Yes |
|
| 1.6.x | ✅ Yes |
|
||||||
| 1.2.x | ✅ Yes |
|
| 1.5.x | ✅ Yes |
|
||||||
|
| 1.4.x | ❌ No |
|
||||||
|
| 1.3.x | ❌ No |
|
||||||
|
| 1.2.x | ❌ No |
|
||||||
| 1.1.x | ❌ No |
|
| 1.1.x | ❌ No |
|
||||||
| 1.0.x | ❌ No |
|
| 1.0.x | ❌ No |
|
||||||
| < 1.0 | ❌ No |
|
| < 1.0 | ❌ No |
|
||||||
@@ -41,6 +44,9 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
|||||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
| 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 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/*` |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,6 +167,7 @@ server {
|
|||||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||||
|
| `GET /api/swagger` | No rate limit (public documentation) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,499 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Webhooks Section Styles */
|
|
||||||
.webhooks-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-header {
|
|
||||||
padding: 20px 30px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-header:hover {
|
|
||||||
background: #f0f1f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-header h2 {
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-toggle {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #666;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-toggle.expanded {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhooks-content {
|
|
||||||
padding: 20px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-instance {
|
|
||||||
padding: 20px 0;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-instance:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-instance h3 {
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 5px 15px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator.enabled {
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator.disabled {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enable-webhook-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enable-webhook-btn:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.enable-webhook-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-webhook-btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #f093fb;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-webhook-btn:hover {
|
|
||||||
background: #d97ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-webhook-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-triggers {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-label {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-value {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-value.active {
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-value.inactive {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stats {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stats-title {
|
|
||||||
color: #999;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stat-label {
|
|
||||||
color: #999;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webhook-stat-value {
|
|
||||||
color: #333;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
||||||
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([]);
|
|
||||||
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
|
||||||
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
|
||||||
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
|
||||||
const [webhookMetrics, setWebhookMetrics] = useState(null);
|
|
||||||
const [webhookLoading, setWebhookLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSessions();
|
|
||||||
fetchWebhookStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchWebhookMetrics = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/dashboard/webhook-metrics');
|
|
||||||
setWebhookMetrics(response.data);
|
|
||||||
return response.data;
|
|
||||||
} catch (err) {
|
|
||||||
// Not fatal — stats just won't display
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchWebhookStatus = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch metrics in parallel with notification status
|
|
||||||
const metricsPromise = fetchWebhookMetrics();
|
|
||||||
|
|
||||||
// Fetch Sonarr notifications
|
|
||||||
let sonarrEnabled = false;
|
|
||||||
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
|
||||||
try {
|
|
||||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
|
||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
|
||||||
sonarrEnabled = !!sonarrSofarr;
|
|
||||||
if (sonarrSofarr) {
|
|
||||||
sonarrTriggers = {
|
|
||||||
onGrab: sonarrSofarr.onGrab,
|
|
||||||
onDownload: sonarrSofarr.onDownload,
|
|
||||||
onImport: sonarrSofarr.onImport,
|
|
||||||
onUpgrade: sonarrSofarr.onUpgrade
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Sonarr not configured or not accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch Radarr notifications
|
|
||||||
let radarrEnabled = false;
|
|
||||||
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
|
||||||
try {
|
|
||||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
|
||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
|
||||||
radarrEnabled = !!radarrSofarr;
|
|
||||||
if (radarrSofarr) {
|
|
||||||
radarrTriggers = {
|
|
||||||
onGrab: radarrSofarr.onGrab,
|
|
||||||
onDownload: radarrSofarr.onDownload,
|
|
||||||
onImport: radarrSofarr.onImport,
|
|
||||||
onUpgrade: radarrSofarr.onUpgrade
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Radarr not configured or not accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = await metricsPromise;
|
|
||||||
|
|
||||||
// Attach per-instance stats from global metrics.
|
|
||||||
// The instances object is keyed by instance URL; we pick the first
|
|
||||||
// sonarr/radarr entry by matching env-configured URLs.
|
|
||||||
const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : [];
|
|
||||||
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
|
||||||
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
|
||||||
|
|
||||||
setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats });
|
|
||||||
setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch webhook status:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableSonarrWebhook = async () => {
|
|
||||||
setWebhookLoading(true);
|
|
||||||
try {
|
|
||||||
await axios.post('/api/sonarr/notifications/sofarr-webhook');
|
|
||||||
await fetchWebhookStatus();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to enable Sonarr webhook:', err);
|
|
||||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
|
||||||
} finally {
|
|
||||||
setWebhookLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableRadarrWebhook = async () => {
|
|
||||||
setWebhookLoading(true);
|
|
||||||
try {
|
|
||||||
await axios.post('/api/radarr/notifications/sofarr-webhook');
|
|
||||||
await fetchWebhookStatus();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to enable Radarr webhook:', err);
|
|
||||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
|
||||||
} finally {
|
|
||||||
setWebhookLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testSonarrWebhook = async () => {
|
|
||||||
setWebhookLoading(true);
|
|
||||||
try {
|
|
||||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
|
||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
|
||||||
if (sonarrSofarr) {
|
|
||||||
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
|
||||||
await fetchWebhookStatus();
|
|
||||||
alert('Sonarr webhook test sent successfully!');
|
|
||||||
} else {
|
|
||||||
alert('Sofarr webhook not configured for Sonarr.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to test Sonarr webhook:', err);
|
|
||||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
|
||||||
} finally {
|
|
||||||
setWebhookLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testRadarrWebhook = async () => {
|
|
||||||
setWebhookLoading(true);
|
|
||||||
try {
|
|
||||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
|
||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
|
||||||
if (radarrSofarr) {
|
|
||||||
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
|
||||||
await fetchWebhookStatus();
|
|
||||||
alert('Radarr webhook test sent successfully!');
|
|
||||||
} else {
|
|
||||||
alert('Sofarr webhook not configured for Radarr.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to test Radarr webhook:', err);
|
|
||||||
alert('Failed to test Radarr webhook. Check console for details.');
|
|
||||||
} finally {
|
|
||||||
setWebhookLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="webhooks-section">
|
|
||||||
<div className="webhooks-header" onClick={() => setWebhookSectionExpanded(!webhookSectionExpanded)}>
|
|
||||||
<h2>⚡ Webhooks Configuration</h2>
|
|
||||||
<span className={`webhooks-toggle ${webhookSectionExpanded ? 'expanded' : ''}`}>▼</span>
|
|
||||||
</div>
|
|
||||||
{webhookSectionExpanded && (
|
|
||||||
<div className="webhooks-content">
|
|
||||||
{webhookLoading && <div className="loading">Loading webhook status...</div>}
|
|
||||||
<div className="webhook-instance">
|
|
||||||
<h3>Sonarr</h3>
|
|
||||||
<div className="webhook-status">
|
|
||||||
<span className={`status-indicator ${sonarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
|
|
||||||
{sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
|
|
||||||
</span>
|
|
||||||
{!sonarrWebhook.enabled && (
|
|
||||||
<button onClick={enableSonarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
|
|
||||||
Enable Sofarr Webhooks
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sonarrWebhook.enabled && (
|
|
||||||
<button onClick={testSonarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
|
|
||||||
Test
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sonarrWebhook.enabled && (
|
|
||||||
<div className="webhook-triggers">
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Grab</span>
|
|
||||||
<span className={`trigger-value ${sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
|
|
||||||
{sonarrWebhook.triggers.onGrab ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Download</span>
|
|
||||||
<span className={`trigger-value ${sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
|
|
||||||
{sonarrWebhook.triggers.onDownload ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Import</span>
|
|
||||||
<span className={`trigger-value ${sonarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
|
|
||||||
{sonarrWebhook.triggers.onImport ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Upgrade</span>
|
|
||||||
<span className={`trigger-value ${sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
|
|
||||||
{sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sonarrWebhook.stats && (
|
|
||||||
<div className="webhook-stats">
|
|
||||||
<div className="webhook-stats-title">Statistics</div>
|
|
||||||
<div className="webhook-stats-grid">
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Events Received</span>
|
|
||||||
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Polls Skipped</span>
|
|
||||||
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Last Event</span>
|
|
||||||
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="webhook-instance">
|
|
||||||
<h3>Radarr</h3>
|
|
||||||
<div className="webhook-status">
|
|
||||||
<span className={`status-indicator ${radarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
|
|
||||||
{radarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
|
|
||||||
</span>
|
|
||||||
{!radarrWebhook.enabled && (
|
|
||||||
<button onClick={enableRadarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
|
|
||||||
Enable Sofarr Webhooks
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{radarrWebhook.enabled && (
|
|
||||||
<button onClick={testRadarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
|
|
||||||
Test
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{radarrWebhook.enabled && (
|
|
||||||
<div className="webhook-triggers">
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Grab</span>
|
|
||||||
<span className={`trigger-value ${radarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
|
|
||||||
{radarrWebhook.triggers.onGrab ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Download</span>
|
|
||||||
<span className={`trigger-value ${radarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
|
|
||||||
{radarrWebhook.triggers.onDownload ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Import</span>
|
|
||||||
<span className={`trigger-value ${radarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
|
|
||||||
{radarrWebhook.triggers.onImport ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="trigger-item">
|
|
||||||
<span className="trigger-label">On Upgrade</span>
|
|
||||||
<span className={`trigger-value ${radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
|
|
||||||
{radarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{radarrWebhook.stats && (
|
|
||||||
<div className="webhook-stats">
|
|
||||||
<div className="webhook-stats-title">Statistics</div>
|
|
||||||
<div className="webhook-stats-grid">
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Events Received</span>
|
|
||||||
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Polls Skipped</span>
|
|
||||||
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="webhook-stat">
|
|
||||||
<span className="webhook-stat-label">Last Event</span>
|
|
||||||
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</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,351 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
export async function checkAuthentication() {
|
||||||
|
try {
|
||||||
|
// Fetch both auth state and a fresh CSRF token in parallel
|
||||||
|
const [meRes, csrfRes] = await Promise.all([
|
||||||
|
fetch('/api/auth/me'),
|
||||||
|
fetch('/api/auth/csrf')
|
||||||
|
]);
|
||||||
|
const data = await meRes.json();
|
||||||
|
const csrfData = await csrfRes.json();
|
||||||
|
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
state.currentUser = data.user;
|
||||||
|
state.isAdmin = !!data.user.isAdmin;
|
||||||
|
return { authenticated: true, user: data.user };
|
||||||
|
} else {
|
||||||
|
return { authenticated: false };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Authentication check failed:', err);
|
||||||
|
return { authenticated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleLogin(username, password, rememberMe) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password, rememberMe })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
state.currentUser = data.user;
|
||||||
|
state.isAdmin = !!data.user.isAdmin;
|
||||||
|
// Store CSRF token returned by login for use in subsequent requests
|
||||||
|
if (data.csrfToken) state.csrfToken = data.csrfToken;
|
||||||
|
return { success: true, user: data.user };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: data.error || 'Login failed' };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return { success: false, error: 'Login failed. Please try again.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: state.csrfToken ? { 'X-CSRF-Token': state.csrfToken } : {}
|
||||||
|
});
|
||||||
|
state.currentUser = null;
|
||||||
|
state.csrfToken = null;
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout failed:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHistory(forceRefresh = false) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ days: state.historyDays });
|
||||||
|
if (state.showAll) params.set('showAll', 'true');
|
||||||
|
if (forceRefresh) params.set('_t', Date.now());
|
||||||
|
const res = await fetch(`/api/history/recent?${params}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return { success: true, history: data.history || [] };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Load error:', err);
|
||||||
|
return { success: false, error: 'Failed to load history.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBlocklistSearch(download) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': state.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
arrQueueId: download.arrQueueId,
|
||||||
|
arrType: download.arrType,
|
||||||
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentType: download.arrContentType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blocklist] Error:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAppVersion() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/health');
|
||||||
|
const data = await res.json();
|
||||||
|
return data.version || null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWebhookMetrics() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/webhook-metrics');
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWebhookStatus() {
|
||||||
|
try {
|
||||||
|
// Fetch metrics in parallel
|
||||||
|
const metricsPromise = fetchWebhookMetrics();
|
||||||
|
|
||||||
|
// Fetch webhook configuration status (checks SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
|
||||||
|
let webhookConfigValid = false;
|
||||||
|
try {
|
||||||
|
const configRes = await fetch('/api/webhook/config');
|
||||||
|
if (configRes.ok) {
|
||||||
|
const configData = await configRes.json();
|
||||||
|
webhookConfigValid = configData.valid || false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Config endpoint not available, assume invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Sonarr notifications
|
||||||
|
let sonarrEnabled = false;
|
||||||
|
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
|
try {
|
||||||
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||||
|
if (sonarrRes.ok) {
|
||||||
|
const sonarrData = await sonarrRes.json();
|
||||||
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||||
|
sonarrEnabled = webhookConfigValid && !!sonarrSofarr;
|
||||||
|
if (sonarrSofarr) {
|
||||||
|
sonarrTriggers = {
|
||||||
|
onGrab: sonarrSofarr.onGrab,
|
||||||
|
onDownload: sonarrSofarr.onDownload,
|
||||||
|
onImport: sonarrSofarr.onImport,
|
||||||
|
onUpgrade: sonarrSofarr.onUpgrade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Sonarr not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Radarr notifications
|
||||||
|
let radarrEnabled = false;
|
||||||
|
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
|
try {
|
||||||
|
const radarrRes = await fetch('/api/radarr/notifications');
|
||||||
|
if (radarrRes.ok) {
|
||||||
|
const radarrData = await radarrRes.json();
|
||||||
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||||
|
radarrEnabled = webhookConfigValid && !!radarrSofarr;
|
||||||
|
if (radarrSofarr) {
|
||||||
|
radarrTriggers = {
|
||||||
|
onGrab: radarrSofarr.onGrab,
|
||||||
|
onDownload: radarrSofarr.onDownload,
|
||||||
|
onImport: radarrSofarr.onImport,
|
||||||
|
onUpgrade: radarrSofarr.onUpgrade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Radarr not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Ombi webhook status
|
||||||
|
let ombiEnabled = false;
|
||||||
|
let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
||||||
|
let ombiStats = null;
|
||||||
|
try {
|
||||||
|
const ombiRes = await fetch('/api/ombi/webhook/status');
|
||||||
|
if (ombiRes.ok) {
|
||||||
|
const ombiData = await ombiRes.json();
|
||||||
|
ombiEnabled = ombiData.enabled || false;
|
||||||
|
ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
||||||
|
ombiStats = ombiData.stats || null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ombi not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
state.webhookMetrics = await metricsPromise;
|
||||||
|
|
||||||
|
// Find instance stats
|
||||||
|
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
|
||||||
|
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||||
|
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||||
|
|
||||||
|
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
||||||
|
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
||||||
|
state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats };
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch webhook status:', err);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableSonarrWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Sonarr webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableRadarrWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Radarr webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSonarrWebhook() {
|
||||||
|
try {
|
||||||
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||||
|
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||||
|
const sonarrData = await sonarrRes.json();
|
||||||
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||||
|
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
|
||||||
|
|
||||||
|
const res = await fetch('/api/sonarr/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': state.csrfToken || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(sonarrSofarr)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Sonarr webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testRadarrWebhook() {
|
||||||
|
try {
|
||||||
|
const radarrRes = await fetch('/api/radarr/notifications');
|
||||||
|
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||||
|
const radarrData = await radarrRes.json();
|
||||||
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||||
|
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
|
||||||
|
|
||||||
|
const res = await fetch('/api/radarr/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': state.csrfToken || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(radarrSofarr)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Radarr webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableOmbiWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ombi/webhook/enable', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Ombi webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testOmbiWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ombi/webhook/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Ombi webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshStatusPanel() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Status] Error fetching status:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
// Bootstrap - wire all event handlers on DOMContentLoaded
|
||||||
|
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
||||||
|
import { initDownloadClientFilter } from './ui/filters.js';
|
||||||
|
import { initRequestFilters } from './ui/requestFilters.js';
|
||||||
|
import { initHistoryControls } from './ui/history.js';
|
||||||
|
import { toggleStatusPanel } from './ui/statusPanel.js';
|
||||||
|
import { initWebhooks } from './ui/webhooks.js';
|
||||||
|
import { initThemeSwitcher } from './ui/theme.js';
|
||||||
|
import { initTabs, goHome } from './ui/tabs.js';
|
||||||
|
import { handleShowAllToggle } from './sse.js';
|
||||||
|
import { loadAppVersion } from './api.js';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Login form
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
if (loginForm) {
|
||||||
|
loginForm.addEventListener('submit', handleLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout button
|
||||||
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener('click', handleLogoutClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all toggle
|
||||||
|
const showAllToggle = document.getElementById('show-all-toggle');
|
||||||
|
if (showAllToggle) {
|
||||||
|
showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status panel toggle
|
||||||
|
const statusToggle = document.getElementById('status-btn');
|
||||||
|
if (statusToggle) {
|
||||||
|
statusToggle.addEventListener('click', toggleStatusPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home button
|
||||||
|
const homeBtn = document.getElementById('home-btn');
|
||||||
|
if (homeBtn) {
|
||||||
|
homeBtn.addEventListener('click', goHome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI components
|
||||||
|
initThemeSwitcher();
|
||||||
|
initTabs();
|
||||||
|
initDownloadClientFilter();
|
||||||
|
initRequestFilters();
|
||||||
|
initHistoryControls();
|
||||||
|
initWebhooks();
|
||||||
|
|
||||||
|
// Load app version
|
||||||
|
loadAppVersion().then(version => {
|
||||||
|
const versionEl = document.getElementById('app-version');
|
||||||
|
if (versionEl && version) {
|
||||||
|
versionEl.textContent = 'v' + version;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check authentication and initialize
|
||||||
|
checkAuthenticationAndInit();
|
||||||
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
||||||
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,565 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||||
|
import { handleBlocklistSearch } from '../api.js';
|
||||||
|
|
||||||
|
export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
if (showAll && tagBadges && tagBadges.length > 0) {
|
||||||
|
const unmatched = tagBadges.filter(b => !b.matchedUser);
|
||||||
|
const matched = tagBadges.filter(b => b.matchedUser);
|
||||||
|
for (const b of unmatched) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'download-user-badge unmatched';
|
||||||
|
badge.textContent = b.label;
|
||||||
|
fragment.appendChild(badge);
|
||||||
|
}
|
||||||
|
for (const b of matched) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'download-user-badge';
|
||||||
|
badge.textContent = b.matchedUser;
|
||||||
|
fragment.appendChild(badge);
|
||||||
|
}
|
||||||
|
} else if (matchedUserTag) {
|
||||||
|
const matchedBadge = document.createElement('span');
|
||||||
|
matchedBadge.className = 'download-user-badge';
|
||||||
|
matchedBadge.textContent = matchedUserTag;
|
||||||
|
fragment.appendChild(matchedBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClientLogo(download) {
|
||||||
|
const clientLogoWrapper = document.createElement('span');
|
||||||
|
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||||
|
|
||||||
|
const clientLogo = document.createElement('img');
|
||||||
|
clientLogo.className = 'download-client-logo';
|
||||||
|
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||||
|
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||||
|
clientLogo.title = download.instanceName || download.client;
|
||||||
|
clientLogo.onerror = () => {
|
||||||
|
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||||
|
clientLogoWrapper.classList.add('fallback');
|
||||||
|
};
|
||||||
|
|
||||||
|
clientLogoWrapper.appendChild(clientLogo);
|
||||||
|
return clientLogoWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServiceIcons(download) {
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.className = 'service-icons-container';
|
||||||
|
|
||||||
|
// Add Ombi icon for all users if ombiLink exists
|
||||||
|
if (download.ombiLink) {
|
||||||
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.className = 'service-icon ombi';
|
||||||
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
|
ombiIcon.alt = 'Ombi';
|
||||||
|
ombiIcon.title = download.ombiTooltip || 'Ombi';
|
||||||
|
ombiIcon.href = download.ombiLink;
|
||||||
|
const ombiLink = document.createElement('a');
|
||||||
|
ombiLink.href = download.ombiLink;
|
||||||
|
ombiLink.target = '_blank';
|
||||||
|
ombiLink.appendChild(ombiIcon);
|
||||||
|
container.appendChild(ombiLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Sonarr/Radarr icon for admin users if arrLink exists
|
||||||
|
if (state.isAdmin && download.arrLink) {
|
||||||
|
const arrIcon = document.createElement('img');
|
||||||
|
if (download.arrType === 'sonarr') {
|
||||||
|
arrIcon.className = 'service-icon sonarr';
|
||||||
|
arrIcon.src = '/images/sonarr.svg';
|
||||||
|
arrIcon.alt = 'Sonarr';
|
||||||
|
} else if (download.arrType === 'radarr') {
|
||||||
|
arrIcon.className = 'service-icon radarr';
|
||||||
|
arrIcon.src = '/images/radarr.svg';
|
||||||
|
arrIcon.alt = 'Radarr';
|
||||||
|
}
|
||||||
|
arrIcon.title = download.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.href = download.arrLink;
|
||||||
|
arrLink.target = '_blank';
|
||||||
|
arrLink.appendChild(arrIcon);
|
||||||
|
container.appendChild(arrLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDownloads() {
|
||||||
|
const downloadsList = document.getElementById('downloads-list');
|
||||||
|
const noDownloads = document.getElementById('no-downloads');
|
||||||
|
|
||||||
|
// Filter downloads by selected clients
|
||||||
|
let filteredDownloads = state.downloads;
|
||||||
|
if (state.selectedDownloadClients.length > 0) {
|
||||||
|
// Map indices to client objects, then filter by both client type and instanceId
|
||||||
|
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
|
||||||
|
filteredDownloads = state.downloads.filter(d =>
|
||||||
|
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort downloads by client order (matching the order in downloadClients)
|
||||||
|
if (state.downloadClients.length > 0) {
|
||||||
|
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
|
||||||
|
filteredDownloads = [...filteredDownloads].sort((a, b) => {
|
||||||
|
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
|
||||||
|
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredDownloads.length === 0) {
|
||||||
|
noDownloads.classList.remove('hidden');
|
||||||
|
downloadsList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noDownloads.classList.add('hidden');
|
||||||
|
|
||||||
|
// Get existing cards
|
||||||
|
const existingCards = new Map();
|
||||||
|
downloadsList.querySelectorAll('.download-card').forEach(card => {
|
||||||
|
existingCards.set(card.dataset.id, card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track which downloads we've processed
|
||||||
|
const processedIds = new Set();
|
||||||
|
|
||||||
|
filteredDownloads.forEach(download => {
|
||||||
|
const id = download.title;
|
||||||
|
processedIds.add(id);
|
||||||
|
|
||||||
|
const existingCard = existingCards.get(id);
|
||||||
|
if (existingCard) {
|
||||||
|
// Update existing card
|
||||||
|
updateDownloadCard(existingCard, download);
|
||||||
|
} else {
|
||||||
|
// Create new card
|
||||||
|
const card = createDownloadCard(download);
|
||||||
|
downloadsList.appendChild(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove cards for downloads that no longer exist
|
||||||
|
existingCards.forEach((card, id) => {
|
||||||
|
if (!processedIds.has(id)) {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDownloadCard(card, download) {
|
||||||
|
// Remove old header-right container if it exists
|
||||||
|
const oldRightSide = card.querySelector('.download-header-right');
|
||||||
|
if (oldRightSide) {
|
||||||
|
oldRightSide.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old user badges directly in header
|
||||||
|
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
|
||||||
|
oldBadges.forEach(badge => badge.remove());
|
||||||
|
|
||||||
|
// Remove old client logo from header (old structure)
|
||||||
|
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
|
||||||
|
if (oldLogoInHeader) {
|
||||||
|
oldLogoInHeader.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old client logo from card (new structure) if it exists
|
||||||
|
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
|
||||||
|
if (oldLogoInCard) {
|
||||||
|
oldLogoInCard.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new right-side container with user badge only
|
||||||
|
const header = card.querySelector('.download-header');
|
||||||
|
if (header && !header.querySelector('.download-header-right')) {
|
||||||
|
const rightSide = document.createElement('div');
|
||||||
|
rightSide.className = 'download-header-right';
|
||||||
|
|
||||||
|
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
|
||||||
|
rightSide.appendChild(badges);
|
||||||
|
|
||||||
|
header.appendChild(rightSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add client logo to card (positioned at bottom right via CSS)
|
||||||
|
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
|
||||||
|
card.appendChild(createClientLogo(download));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const statusEl = card.querySelector('.download-status');
|
||||||
|
if (statusEl && statusEl.textContent !== download.status) {
|
||||||
|
statusEl.textContent = download.status;
|
||||||
|
statusEl.className = `download-status ${download.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bar and missing pieces
|
||||||
|
const progressContainer = card.querySelector('.progress-container');
|
||||||
|
if (progressContainer && download.progress !== undefined) {
|
||||||
|
const progressBar = progressContainer.querySelector('.progress-bar');
|
||||||
|
const progressText = progressContainer.querySelector('.progress-text');
|
||||||
|
const missingText = progressContainer.querySelector('.missing-text');
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
const downloaded = progressBar.querySelector('.downloaded');
|
||||||
|
if (downloaded) {
|
||||||
|
downloaded.style.width = download.progress + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressText) {
|
||||||
|
progressText.textContent = download.progress + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingText) {
|
||||||
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||||
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||||
|
if (missingMb > 0 && totalMb > 0) {
|
||||||
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||||
|
} else {
|
||||||
|
missingText.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update speed
|
||||||
|
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
|
||||||
|
if (speedEl && download.speed !== undefined) {
|
||||||
|
speedEl.textContent = formatSpeed(download.speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ETA
|
||||||
|
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
|
||||||
|
if (etaEl && download.eta !== undefined) {
|
||||||
|
etaEl.textContent = download.eta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update qBittorrent-specific fields
|
||||||
|
if (download.qbittorrent) {
|
||||||
|
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
|
||||||
|
if (seedsEl && download.seeds !== undefined) {
|
||||||
|
seedsEl.textContent = download.seeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
|
||||||
|
if (peersEl && download.peers !== undefined) {
|
||||||
|
peersEl.textContent = download.peers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||||
|
if (availabilityItem && download.availability !== undefined) {
|
||||||
|
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||||
|
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBlocklistSearchClick(btn, download) {
|
||||||
|
console.log('[Blocklist] Clicked, download:', download);
|
||||||
|
console.log('[Blocklist] Required fields:', {
|
||||||
|
arrQueueId: download.arrQueueId,
|
||||||
|
arrType: download.arrType,
|
||||||
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentType: download.arrContentType,
|
||||||
|
isAdmin: state.isAdmin,
|
||||||
|
canBlocklist: download.canBlocklist
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Working…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleBlocklistSearch(download);
|
||||||
|
btn.textContent = '✓ Done — searching…';
|
||||||
|
btn.className = 'blocklist-search-btn success';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blocklist] Error:', err);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⛔ Blocklist & Search';
|
||||||
|
btn.className = 'blocklist-search-btn error';
|
||||||
|
btn.title = `Failed: ${err.message}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.className = 'blocklist-search-btn';
|
||||||
|
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger an automatic search';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDownloadCard(download) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `download-card ${download.type}`;
|
||||||
|
card.dataset.id = download.title;
|
||||||
|
|
||||||
|
// Cover art
|
||||||
|
if (download.coverArt) {
|
||||||
|
const coverDiv = document.createElement('div');
|
||||||
|
coverDiv.className = 'download-cover';
|
||||||
|
const coverImg = document.createElement('img');
|
||||||
|
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||||
|
// is satisfied (external poster URLs would be blocked otherwise).
|
||||||
|
coverImg.src = download.coverArt
|
||||||
|
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||||
|
: '';
|
||||||
|
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||||
|
coverImg.loading = 'lazy';
|
||||||
|
coverDiv.appendChild(coverImg);
|
||||||
|
card.appendChild(coverDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info wrapper
|
||||||
|
const infoDiv = document.createElement('div');
|
||||||
|
infoDiv.className = 'download-info';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'download-header';
|
||||||
|
|
||||||
|
const type = document.createElement('span');
|
||||||
|
type.className = `download-type ${download.type}`;
|
||||||
|
if (download.type === 'series') {
|
||||||
|
type.textContent = '📺 Series';
|
||||||
|
} else if (download.type === 'movie') {
|
||||||
|
type.textContent = '🎬 Movie';
|
||||||
|
} else if (download.type === 'torrent') {
|
||||||
|
const instName = download.instanceName ? ` (${download.instanceName})` : '';
|
||||||
|
type.textContent = `📥 Torrent${instName}`;
|
||||||
|
} else {
|
||||||
|
type.textContent = download.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = `download-status ${download.status}`;
|
||||||
|
status.textContent = download.status;
|
||||||
|
|
||||||
|
header.appendChild(type);
|
||||||
|
header.appendChild(status);
|
||||||
|
|
||||||
|
if (download.importIssues && download.importIssues.length > 0) {
|
||||||
|
const issueBadge = document.createElement('span');
|
||||||
|
issueBadge.className = 'import-issue-badge';
|
||||||
|
issueBadge.textContent = 'Import Pending';
|
||||||
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||||
|
header.appendChild(issueBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||||
|
const blBtn = document.createElement('button');
|
||||||
|
blBtn.className = 'blocklist-search-btn';
|
||||||
|
blBtn.textContent = '⛔ Blocklist & Search';
|
||||||
|
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||||
|
blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download));
|
||||||
|
header.appendChild(blBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right side container for user badge only
|
||||||
|
const rightSide = document.createElement('div');
|
||||||
|
rightSide.className = 'download-header-right';
|
||||||
|
|
||||||
|
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
|
||||||
|
rightSide.appendChild(badges);
|
||||||
|
|
||||||
|
header.appendChild(rightSide);
|
||||||
|
|
||||||
|
// Add client logo to card (positioned at bottom right via CSS)
|
||||||
|
if (download.client) {
|
||||||
|
card.appendChild(createClientLogo(download));
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.className = 'download-title';
|
||||||
|
title.textContent = download.title;
|
||||||
|
|
||||||
|
infoDiv.appendChild(header);
|
||||||
|
infoDiv.appendChild(title);
|
||||||
|
|
||||||
|
if (download.seriesName) {
|
||||||
|
const series = document.createElement('p');
|
||||||
|
series.className = 'download-series';
|
||||||
|
|
||||||
|
// Add service icons
|
||||||
|
const serviceIcons = createServiceIcons(download);
|
||||||
|
if (serviceIcons.hasChildNodes()) {
|
||||||
|
series.appendChild(serviceIcons);
|
||||||
|
series.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series name is now plain text for all users (no link)
|
||||||
|
const seriesText = document.createElement('span');
|
||||||
|
seriesText.textContent = `Series: ${download.seriesName}`;
|
||||||
|
series.appendChild(seriesText);
|
||||||
|
|
||||||
|
infoDiv.appendChild(series);
|
||||||
|
const epEl = formatEpisodeInfo(download.episodes);
|
||||||
|
if (epEl) infoDiv.appendChild(epEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.movieName) {
|
||||||
|
const movie = document.createElement('p');
|
||||||
|
movie.className = 'download-movie';
|
||||||
|
|
||||||
|
// Add service icons
|
||||||
|
const serviceIcons = createServiceIcons(download);
|
||||||
|
if (serviceIcons.hasChildNodes()) {
|
||||||
|
movie.appendChild(serviceIcons);
|
||||||
|
movie.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movie name is now plain text for all users (no link)
|
||||||
|
const movieText = document.createElement('span');
|
||||||
|
movieText.textContent = `Movie: ${download.movieName}`;
|
||||||
|
movie.appendChild(movieText);
|
||||||
|
|
||||||
|
infoDiv.appendChild(movie);
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.className = 'download-details';
|
||||||
|
|
||||||
|
const size = createDetailItem('Size', formatSize(download.size));
|
||||||
|
details.appendChild(size);
|
||||||
|
|
||||||
|
if (download.progress !== undefined) {
|
||||||
|
const progressItem = document.createElement('div');
|
||||||
|
progressItem.className = 'detail-item progress-item';
|
||||||
|
progressItem.dataset.label = 'Progress';
|
||||||
|
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'detail-label';
|
||||||
|
labelSpan.textContent = 'Progress';
|
||||||
|
|
||||||
|
const valueDiv = document.createElement('div');
|
||||||
|
valueDiv.className = 'progress-container';
|
||||||
|
|
||||||
|
// Progress bar with segments
|
||||||
|
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||||
|
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||||
|
const downloadedMb = totalMb - missingMb;
|
||||||
|
const progressPercent = parseFloat(download.progress) || 0;
|
||||||
|
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
|
||||||
|
|
||||||
|
const progressBar = document.createElement('div');
|
||||||
|
progressBar.className = 'progress-bar';
|
||||||
|
|
||||||
|
// Downloaded portion (green)
|
||||||
|
if (progressPercent > 0) {
|
||||||
|
const downloaded = document.createElement('div');
|
||||||
|
downloaded.className = 'progress-segment downloaded';
|
||||||
|
downloaded.style.width = progressPercent + '%';
|
||||||
|
progressBar.appendChild(downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
valueDiv.appendChild(progressBar);
|
||||||
|
|
||||||
|
// Text showing percentage
|
||||||
|
const progressText = document.createElement('span');
|
||||||
|
progressText.className = 'progress-text';
|
||||||
|
progressText.textContent = download.progress + '%';
|
||||||
|
valueDiv.appendChild(progressText);
|
||||||
|
|
||||||
|
// Missing pieces text (only for torrent clients like qBittorrent)
|
||||||
|
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
|
||||||
|
const missingText = document.createElement('span');
|
||||||
|
missingText.className = 'missing-text';
|
||||||
|
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||||
|
valueDiv.appendChild(missingText);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressItem.appendChild(labelSpan);
|
||||||
|
progressItem.appendChild(valueDiv);
|
||||||
|
details.appendChild(progressItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.speed && download.speed > 0) {
|
||||||
|
const speed = createDetailItem('Speed', formatSpeed(download.speed));
|
||||||
|
details.appendChild(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.eta) {
|
||||||
|
const eta = createDetailItem('ETA', download.eta);
|
||||||
|
details.appendChild(eta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// qBittorrent-specific fields
|
||||||
|
if (download.qbittorrent) {
|
||||||
|
if (download.seeds !== undefined) {
|
||||||
|
const seeds = createDetailItem('Seeds', download.seeds);
|
||||||
|
details.appendChild(seeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.peers !== undefined) {
|
||||||
|
const peers = createDetailItem('Peers', download.peers);
|
||||||
|
details.appendChild(peers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.availability !== undefined) {
|
||||||
|
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||||
|
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||||
|
details.appendChild(availability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download.completedAt) {
|
||||||
|
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||||
|
details.appendChild(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && (download.downloadPath || download.targetPath)) {
|
||||||
|
const pathsDiv = document.createElement('div');
|
||||||
|
pathsDiv.className = 'download-paths';
|
||||||
|
if (download.downloadPath) {
|
||||||
|
const dlPath = document.createElement('div');
|
||||||
|
dlPath.className = 'path-item';
|
||||||
|
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||||
|
pathsDiv.appendChild(dlPath);
|
||||||
|
}
|
||||||
|
if (download.targetPath) {
|
||||||
|
const tgtPath = document.createElement('div');
|
||||||
|
tgtPath.className = 'path-item';
|
||||||
|
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||||
|
pathsDiv.appendChild(tgtPath);
|
||||||
|
}
|
||||||
|
details.appendChild(pathsDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
infoDiv.appendChild(details);
|
||||||
|
card.appendChild(infoDiv);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDetailItem(label, value) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'detail-item';
|
||||||
|
item.dataset.label = label;
|
||||||
|
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'detail-label';
|
||||||
|
labelSpan.textContent = label;
|
||||||
|
|
||||||
|
const valueSpan = document.createElement('span');
|
||||||
|
valueSpan.className = 'detail-value';
|
||||||
|
valueSpan.textContent = value;
|
||||||
|
|
||||||
|
item.appendChild(labelSpan);
|
||||||
|
item.appendChild(valueSpan);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
}
|
||||||
@@ -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,175 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { escapeHtml } from '../utils/format.js';
|
||||||
|
import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to extract the username from an Ombi request object.
|
||||||
|
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
|
||||||
|
* not a string, so we need to extract the username from the object.
|
||||||
|
*
|
||||||
|
* Must stay in sync with server/utils/ombiHelpers.js
|
||||||
|
*
|
||||||
|
* @param {Object} request - The Ombi request object
|
||||||
|
* @returns {string} The extracted username, or empty string if not found
|
||||||
|
*/
|
||||||
|
function extractRequestedUser(request) {
|
||||||
|
if (!request) return '';
|
||||||
|
|
||||||
|
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||||
|
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
||||||
|
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||||
|
return request.requestedUser.alias ||
|
||||||
|
request.requestedUser.userAlias ||
|
||||||
|
request.requestedUser.userName ||
|
||||||
|
request.requestedUser.normalizedUserName ||
|
||||||
|
request.requestedByAlias || '';
|
||||||
|
}
|
||||||
|
// Handle string format (fallback for compatibility)
|
||||||
|
return request.requestedUser || request.requestedByAlias || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRequests() {
|
||||||
|
const requestsList = document.getElementById('requests-list');
|
||||||
|
const noRequests = document.getElementById('no-requests');
|
||||||
|
|
||||||
|
if (!requestsList) return;
|
||||||
|
|
||||||
|
const ombiRequests = state.ombiRequests || { movie: [], tv: [] };
|
||||||
|
const allRequests = [
|
||||||
|
...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })),
|
||||||
|
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply client-side filters, sorting, and search
|
||||||
|
const filtered = applyRequestFilters(allRequests, {
|
||||||
|
types: state.selectedRequestTypes,
|
||||||
|
statuses: state.selectedRequestStatuses,
|
||||||
|
sort: state.requestSortMode,
|
||||||
|
search: state.requestSearchQuery
|
||||||
|
});
|
||||||
|
|
||||||
|
requestsList.innerHTML = '';
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
if (noRequests) {
|
||||||
|
noRequests.style.display = 'block';
|
||||||
|
const p = noRequests.querySelector('p');
|
||||||
|
if (p) {
|
||||||
|
// Differentiate between no data from Ombi vs filters excluded everything
|
||||||
|
const hasAnyData = allRequests.length > 0;
|
||||||
|
p.textContent = hasAnyData
|
||||||
|
? 'No requests match your filters.'
|
||||||
|
: 'No requests found.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noRequests) noRequests.style.display = 'none';
|
||||||
|
|
||||||
|
filtered.forEach(request => {
|
||||||
|
const card = createRequestCard(request);
|
||||||
|
requestsList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestCard(request) {
|
||||||
|
if (!request) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'request-card';
|
||||||
|
card.textContent = 'Invalid request data';
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'request-card';
|
||||||
|
|
||||||
|
const typeIcon = document.createElement('span');
|
||||||
|
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
|
||||||
|
typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'request-content';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'request-title';
|
||||||
|
title.textContent = request.title || 'Unknown Title';
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'request-meta';
|
||||||
|
|
||||||
|
const statusBadge = createStatusBadge(request);
|
||||||
|
meta.appendChild(statusBadge);
|
||||||
|
|
||||||
|
if (request.year) {
|
||||||
|
const year = document.createElement('span');
|
||||||
|
year.className = 'request-year';
|
||||||
|
year.textContent = request.year;
|
||||||
|
meta.appendChild(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = extractRequestedUser(request);
|
||||||
|
if (username) {
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
|
user.textContent = `Requested by: ${username}`;
|
||||||
|
meta.appendChild(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.quality) {
|
||||||
|
const quality = document.createElement('span');
|
||||||
|
quality.className = 'request-quality';
|
||||||
|
quality.textContent = request.quality;
|
||||||
|
meta.appendChild(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.appendChild(title);
|
||||||
|
content.appendChild(meta);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'request-actions';
|
||||||
|
|
||||||
|
if (state.ombiBaseUrl && request.theMovieDbId) {
|
||||||
|
const ombiLink = document.createElement('a');
|
||||||
|
ombiLink.className = 'request-link ombi-link';
|
||||||
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
|
||||||
|
ombiLink.target = '_blank';
|
||||||
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
|
ombiIcon.alt = 'Ombi';
|
||||||
|
ombiIcon.className = 'request-icon';
|
||||||
|
|
||||||
|
ombiLink.appendChild(ombiIcon);
|
||||||
|
actions.appendChild(ombiLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(typeIcon);
|
||||||
|
card.appendChild(content);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBadge(request) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'request-status-badge';
|
||||||
|
|
||||||
|
const status = getRequestStatus(request);
|
||||||
|
const statusTexts = {
|
||||||
|
available: 'Available',
|
||||||
|
denied: `Denied: ${request.deniedReason || 'No reason'}`,
|
||||||
|
approved: 'Approved',
|
||||||
|
pending: 'Pending',
|
||||||
|
unknown: 'Unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
badge.classList.add(status);
|
||||||
|
badge.textContent = statusTexts[status] || 'Unknown';
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,27 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { getTheme, saveTheme } from '../utils/storage.js';
|
||||||
|
|
||||||
|
// Apply saved theme immediately on load
|
||||||
|
(function applyTheme() {
|
||||||
|
const theme = getTheme();
|
||||||
|
if (theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function initThemeSwitcher() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (!themeToggle) return;
|
||||||
|
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
const currentTheme = getTheme();
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(newTheme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
saveTheme(theme);
|
||||||
|
}
|
||||||
@@ -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,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,107 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Pure filter / sort / search utilities for Ombi requests.
|
||||||
|
* Must stay in sync with server/utils/ombiFilters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a single status string from an Ombi request object.
|
||||||
|
* Priority: available > denied > approved > pending > unknown
|
||||||
|
*
|
||||||
|
* @param {Object} request
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getRequestStatus(request) {
|
||||||
|
if (!request) return 'unknown';
|
||||||
|
if (request.available) return 'available';
|
||||||
|
if (request.denied) return 'denied';
|
||||||
|
if (request.approved) return 'approved';
|
||||||
|
if (request.requested) return 'pending';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter requests by media type.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {string[]} types
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function filterByType(requests, types) {
|
||||||
|
if (!types || types.length === 0) return requests;
|
||||||
|
const normalized = types.map(t => t.toLowerCase());
|
||||||
|
if (normalized.includes('all')) return requests;
|
||||||
|
return requests.filter(r => normalized.includes(r.mediaType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter requests by status.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {string[]} statuses
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function filterByStatus(requests, statuses) {
|
||||||
|
if (!statuses || statuses.length === 0) return requests;
|
||||||
|
const normalized = statuses.map(s => s.toLowerCase());
|
||||||
|
return requests.filter(r => normalized.includes(getRequestStatus(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter requests by case-insensitive title substring.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function filterBySearch(requests, query) {
|
||||||
|
if (!query || query.trim() === '') return requests;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort requests by the given sort mode.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {string} sortMode
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function sortRequests(requests, sortMode) {
|
||||||
|
const sorted = [...requests];
|
||||||
|
switch (sortMode) {
|
||||||
|
case 'requestedDate_asc':
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||||
|
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
case 'title_asc':
|
||||||
|
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||||
|
case 'title_desc':
|
||||||
|
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
||||||
|
case 'requestedDate_desc':
|
||||||
|
default:
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||||
|
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||||
|
return db - da;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all filters and sorting in one call.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {Object} options
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
|
||||||
|
let result = [...requests];
|
||||||
|
result = filterByType(result, types);
|
||||||
|
result = filterByStatus(result, statuses);
|
||||||
|
result = filterBySearch(result, search);
|
||||||
|
result = sortRequests(result, sort);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -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,9 +1,21 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -44,13 +44,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Persistent volume for token store and log file
|
# Persistent volume for token store and log file
|
||||||
- sofarr-data:/app/data
|
- 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)
|
# 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.crt:/app/certs/server.crt:ro
|
||||||
# - /path/to/your/server.key:/app/certs/server.key:ro
|
# - /path/to/your/server.key:/app/certs/server.key:ro
|
||||||
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
# Read-only root filesystem; only the data volume is writable
|
# 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:
|
tmpfs:
|
||||||
- /tmp # Node.js needs a writable /tmp
|
- /tmp # Node.js needs a writable /tmp
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.5.2",
|
"version": "1.7.10",
|
||||||
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"audit": "npm audit --audit-level=high",
|
"audit": "npm audit --audit-level=high",
|
||||||
"audit:fix": "npm audit fix",
|
"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": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
@@ -23,10 +26,15 @@
|
|||||||
"express-rate-limit": "^7.0.0",
|
"express-rate-limit": "^7.0.0",
|
||||||
"helmet": "^7.0.0",
|
"helmet": "^7.0.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"xmlrpc": "^1.3.2"
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"xmlrpc": "^1.3.2",
|
||||||
|
"yamljs": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@stoplight/spectral-cli": "^6.16.0",
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"concurrently": "^7.6.0",
|
"concurrently": "^7.6.0",
|
||||||
"nock": "^14.0.15",
|
"nock": "^14.0.15",
|
||||||
"nodemon": "^3.1.14",
|
"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 |
@@ -1,14 +1,320 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Media Download Dashboard</title>
|
<title>sofarr - Your Downloads Dashboard</title>
|
||||||
<script type="module" crossorigin src="app.js"></script>
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash-screen" class="splash-screen">
|
||||||
</body>
|
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<!-- Login Form -->
|
||||||
|
<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>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input type="text" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group--checkbox">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="remember-me" name="rememberMe">
|
||||||
|
<span>Keep me logged in</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="login-btn">Login</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error-message hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<div id="dashboard-container" class="dashboard-container hidden">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||||
|
<div class="header-controls">
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn active" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-controls" class="admin-controls hidden">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="show-all-toggle">
|
||||||
|
<span>Show all users</span>
|
||||||
|
</label>
|
||||||
|
<button id="status-btn" class="status-btn">Status</button>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-label">Current User:</span>
|
||||||
|
<span class="user-name" id="currentUser">-</span>
|
||||||
|
<button id="logout-btn" class="logout-btn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="status-panel" class="status-panel hidden">
|
||||||
|
<!-- Status content gets rendered here -->
|
||||||
|
<div id="status-content"><p class="status-loading">Loading status...</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
|
||||||
|
<div class="webhooks-section hidden" id="webhooks-section">
|
||||||
|
<div class="webhooks-header" id="webhooks-header">
|
||||||
|
<h2>⚡ Webhooks Configuration</h2>
|
||||||
|
<span class="webhooks-toggle" id="webhooks-toggle">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhooks-content hidden" id="webhooks-content">
|
||||||
|
<div id="webhook-loading" class="webhook-loading hidden">Loading webhook status...</div>
|
||||||
|
|
||||||
|
<!-- Sonarr Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Sonarr</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
|
||||||
|
<button id="enable-sonarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-sonarr-webhook" class="test-webhook-btn hidden">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers hidden" id="sonarr-triggers">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats hidden" id="sonarr-stats">
|
||||||
|
<div class="webhook-stats-title">Statistics</div>
|
||||||
|
<div class="webhook-stats-grid">
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radarr Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Radarr</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="radarr-status">○ Disabled</span>
|
||||||
|
<button id="enable-radarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-radarr-webhook" class="test-webhook-btn hidden">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers hidden" id="radarr-triggers">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats hidden" id="radarr-stats">
|
||||||
|
<div class="webhook-stats-title">Statistics</div>
|
||||||
|
<div class="webhook-stats-grid">
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ombi Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Ombi</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="ombi-status">○ Disabled</span>
|
||||||
|
<button id="enable-ombi-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-ombi-webhook" class="test-webhook-btn hidden">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers hidden" id="ombi-triggers">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Available</span><span class="trigger-value" id="ombi-requestAvailable">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Approved</span><span class="trigger-value" id="ombi-requestApproved">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Declined</span><span class="trigger-value" id="ombi-requestDeclined">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Pending</span><span class="trigger-value" id="ombi-requestPending">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Processing</span><span class="trigger-value" id="ombi-requestProcessing">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats hidden" id="ombi-stats">
|
||||||
|
<div class="webhook-stats-title">Statistics</div>
|
||||||
|
<div class="webhook-stats-grid">
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="ombi-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="ombi-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="ombi-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error-message" class="error-message hidden"></div>
|
||||||
|
|
||||||
|
<div id="loading" class="loading hidden">Loading downloads...</div>
|
||||||
|
|
||||||
|
<div class="main-tabs">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||||
|
<button class="tab-btn" data-tab="requests">Requests</button>
|
||||||
|
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-downloads">
|
||||||
|
<div class="downloads-container">
|
||||||
|
<div class="downloads-header">
|
||||||
|
<div class="downloads-controls">
|
||||||
|
<label class="download-client-label" for="download-client-filter">Download client:</label>
|
||||||
|
<div class="download-client-filter" id="download-client-filter">
|
||||||
|
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
|
||||||
|
<span id="download-client-selected-text">All clients</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="download-client-dropdown" id="download-client-dropdown">
|
||||||
|
<div class="download-client-dropdown-header">
|
||||||
|
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
|
||||||
|
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="download-client-options" id="download-client-options">
|
||||||
|
<!-- Options will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="no-downloads" class="no-downloads hidden">
|
||||||
|
<p>No downloads found for your user.</p>
|
||||||
|
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-list" class="downloads-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel hidden" id="tab-requests">
|
||||||
|
<div class="requests-container">
|
||||||
|
<div class="requests-header">
|
||||||
|
<div class="requests-controls">
|
||||||
|
<!-- Media Type Filter -->
|
||||||
|
<div class="request-filter" id="request-type-filter">
|
||||||
|
<label class="request-filter-label">Type:</label>
|
||||||
|
<button class="request-filter-btn" id="request-type-filter-btn" type="button" aria-expanded="false">
|
||||||
|
<span id="request-type-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-type-select-all" type="button">Select All</button>
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-type-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-type-options">
|
||||||
|
<div class="request-filter-option" data-value="movie">
|
||||||
|
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-movie">Movies</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="tv">
|
||||||
|
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-tv">TV Shows</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="request-filter" id="request-status-filter">
|
||||||
|
<label class="request-filter-label">Status:</label>
|
||||||
|
<button class="request-filter-btn" id="request-status-filter-btn" type="button" aria-expanded="false">
|
||||||
|
<span id="request-status-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-status-select-all" type="button">Select All</button>
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-status-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-status-options">
|
||||||
|
<div class="request-filter-option" data-value="pending">
|
||||||
|
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-pending">Pending</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="approved">
|
||||||
|
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-approved">Approved</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="available">
|
||||||
|
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-available">Available</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="denied">
|
||||||
|
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-denied">Denied</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<div class="request-sort">
|
||||||
|
<label class="request-filter-label" for="request-sort-select">Sort:</label>
|
||||||
|
<select id="request-sort-select" class="request-sort-select">
|
||||||
|
<option value="requestedDate_desc">Newest to oldest</option>
|
||||||
|
<option value="requestedDate_asc">Oldest to newest</option>
|
||||||
|
<option value="title_asc">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">
|
||||||
|
<label class="history-days-label" for="history-days">Last</label>
|
||||||
|
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||||
|
<span class="history-days-label">days</span>
|
||||||
|
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||||
|
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
||||||
|
<input type="checkbox" id="ignore-available-toggle">
|
||||||
|
<span>Hide upgrade failures</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="history-loading" class="history-loading hidden">Loading history...</div>
|
||||||
|
<div id="history-error" class="history-error hidden"></div>
|
||||||
|
<div id="no-history" class="no-history hidden">
|
||||||
|
<p>No completed downloads found in this period.</p>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||||
|
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -11,20 +11,44 @@ const cookieParser = require('cookie-parser');
|
|||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const swaggerUi = require('swagger-ui-express');
|
||||||
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
|
const YAML = require('yamljs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const statusRoutes = require('./routes/status');
|
||||||
const historyRoutes = require('./routes/history');
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
|
const ombiRoutes = require('./routes/ombi');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
function createApp({ skipRateLimits = false } = {}) {
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
const app = express();
|
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) {
|
if (process.env.TRUST_PROXY) {
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
? parseInt(process.env.TRUST_PROXY, 10)
|
||||||
@@ -72,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,10 +104,75 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use(express.json({ limit: '64kb' }));
|
app.use(express.json({ limit: '64kb' }));
|
||||||
|
|
||||||
// Health / readiness (no auth, no rate-limit)
|
// Health / readiness (no auth, no rate-limit)
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /health:
|
||||||
|
* get:
|
||||||
|
* tags: [Health]
|
||||||
|
* summary: Health check
|
||||||
|
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
||||||
|
* security: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Server is healthy
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "ok"
|
||||||
|
* uptime:
|
||||||
|
* type: number
|
||||||
|
* description: Server uptime in seconds
|
||||||
|
* example: 3600.5
|
||||||
|
* x-code-samples:
|
||||||
|
* - lang: curl
|
||||||
|
* label: cURL
|
||||||
|
* source: curl http://localhost:3001/health
|
||||||
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
res.json({ status: 'ok', uptime: process.uptime() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ready:
|
||||||
|
* get:
|
||||||
|
* tags: [Health]
|
||||||
|
* summary: Readiness check
|
||||||
|
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
||||||
|
* security: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Server is ready
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "ready"
|
||||||
|
* '503':
|
||||||
|
* description: Server not ready
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "not ready"
|
||||||
|
* reason:
|
||||||
|
* type: string
|
||||||
|
* example: "EMBY_URL not configured"
|
||||||
|
* x-code-samples:
|
||||||
|
* - lang: curl
|
||||||
|
* label: cURL
|
||||||
|
* source: curl http://localhost:3001/ready
|
||||||
|
*/
|
||||||
app.get('/ready', (req, res) => {
|
app.get('/ready', (req, res) => {
|
||||||
const ready = !!(process.env.EMBY_URL);
|
const ready = !!(process.env.EMBY_URL);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -92,6 +182,33 @@ 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
|
// API routes
|
||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
@@ -103,7 +220,9 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/sonarr', sonarrRoutes);
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
|
app.use('/api/ombi', ombiRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const axios = require('axios');
|
||||||
|
const { logToFile } = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ombi API client for fetching requests and searching media.
|
||||||
|
* Provides integration with Ombi request management system.
|
||||||
|
*/
|
||||||
|
class OmbiClient {
|
||||||
|
constructor(url, apiKey) {
|
||||||
|
this.url = url.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.axios = axios.create({
|
||||||
|
headers: { 'ApiKey': this.apiKey },
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all movie requests from Ombi
|
||||||
|
* @returns {Promise<Array>} Array of movie request objects
|
||||||
|
*/
|
||||||
|
async getMovieRequests() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Movie requests error: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all TV requests from Ombi
|
||||||
|
* @returns {Promise<Array>} Array of TV request objects
|
||||||
|
*/
|
||||||
|
async getTvRequests() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Request/tv`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] TV requests error: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for movies by TMDB ID
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchMovieByTmdbId(tmdbId) {
|
||||||
|
if (!tmdbId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/${tmdbId}`);
|
||||||
|
return response.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Movie search error for TMDB ID ${tmdbId}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for movies by IMDB ID
|
||||||
|
* @param {string} imdbId - IMDB ID
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchMovieByImdbId(imdbId) {
|
||||||
|
if (!imdbId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/imdb/${imdbId}`);
|
||||||
|
return response.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Movie search error for IMDB ID ${imdbId}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for TV shows by TVDB ID
|
||||||
|
* @param {string} tvdbId - TheTVDB ID
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchTvByTvdbId(tvdbId) {
|
||||||
|
if (!tvdbId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/${tvdbId}`);
|
||||||
|
return response.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] TV search error for TVDB ID ${tvdbId}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for TV shows by TMDB ID
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchTvByTmdbId(tmdbId) {
|
||||||
|
if (!tmdbId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/tmdb/${tmdbId}`);
|
||||||
|
return response.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] TV search error for TMDB ID ${tmdbId}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Ombi API
|
||||||
|
* @returns {Promise<boolean>} True if connection is successful
|
||||||
|
*/
|
||||||
|
async testConnection() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
|
||||||
|
return response.status === 200;
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Connection test failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OmbiClient;
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const ArrRetriever = require('./ArrRetriever');
|
||||||
|
const OmbiClient = require('./OmbiClient');
|
||||||
|
const { logToFile } = require('../utils/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ombi data retriever with caching support.
|
||||||
|
* Extends ArrRetriever for PALDRA compliance.
|
||||||
|
* Manages Ombi request data and provides lookup maps for efficient matching.
|
||||||
|
*/
|
||||||
|
class OmbiRetriever extends ArrRetriever {
|
||||||
|
constructor(instanceConfig) {
|
||||||
|
super(instanceConfig);
|
||||||
|
this.client = new OmbiClient(this.url, this.apiKey);
|
||||||
|
this.baseUrl = this.url;
|
||||||
|
this.cache = {
|
||||||
|
movieRequests: [],
|
||||||
|
tvRequests: [],
|
||||||
|
movieMap: new Map(), // tmdbId -> request
|
||||||
|
tvMap: new Map(), // tvdbId -> request
|
||||||
|
lastFetch: 0,
|
||||||
|
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retriever type
|
||||||
|
* @returns {string} The retriever type
|
||||||
|
*/
|
||||||
|
getRetrieverType() {
|
||||||
|
return 'ombi';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unique instance ID
|
||||||
|
* @returns {string} The instance ID
|
||||||
|
*/
|
||||||
|
getInstanceId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tags from Ombi (not applicable, returns empty array)
|
||||||
|
* @returns {Promise<Array>} Empty array (Ombi doesn't have tags)
|
||||||
|
*/
|
||||||
|
async getTags() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue from Ombi (active requests)
|
||||||
|
* @returns {Promise<Object>} Queue object with records array
|
||||||
|
*/
|
||||||
|
async getQueue() {
|
||||||
|
await this.refreshCache();
|
||||||
|
return {
|
||||||
|
records: [...this.cache.movieRequests, ...this.cache.tvRequests]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get history from Ombi (not applicable, returns empty records)
|
||||||
|
* @param {Object} options - Optional parameters (ignored for Ombi)
|
||||||
|
* @returns {Promise<Object>} History object with empty records array
|
||||||
|
*/
|
||||||
|
async getHistory(options = {}) {
|
||||||
|
return {
|
||||||
|
records: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Ombi
|
||||||
|
* @returns {Promise<boolean>} True if connection is successful
|
||||||
|
*/
|
||||||
|
async testConnection() {
|
||||||
|
return await this.client.testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cache is expired
|
||||||
|
* @returns {boolean} True if cache needs refresh
|
||||||
|
*/
|
||||||
|
isCacheExpired() {
|
||||||
|
return Date.now() - this.cache.lastFetch > this.cache.ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh cached data from Ombi API
|
||||||
|
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async refreshCache(force = false) {
|
||||||
|
if (!force && !this.isCacheExpired()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logToFile('[OmbiRetriever] Refreshing cache');
|
||||||
|
|
||||||
|
// Fetch requests in parallel
|
||||||
|
const [movieRequests, tvRequests] = await Promise.all([
|
||||||
|
this.client.getMovieRequests(),
|
||||||
|
this.client.getTvRequests()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this.cache.movieRequests = movieRequests;
|
||||||
|
this.cache.tvRequests = tvRequests;
|
||||||
|
this.cache.lastFetch = Date.now();
|
||||||
|
|
||||||
|
// Build lookup maps
|
||||||
|
this.cache.movieMap.clear();
|
||||||
|
this.cache.tvMap.clear();
|
||||||
|
|
||||||
|
// Build movie map (tmdbId -> request)
|
||||||
|
movieRequests.forEach(request => {
|
||||||
|
if (request.theMovieDbId) {
|
||||||
|
this.cache.movieMap.set(request.theMovieDbId, request);
|
||||||
|
}
|
||||||
|
if (request.imdbId) {
|
||||||
|
this.cache.movieMap.set(request.imdbId, request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build TV map (tvdbId -> request, fallback to tmdbId)
|
||||||
|
tvRequests.forEach(request => {
|
||||||
|
if (request.theTvDbId) {
|
||||||
|
this.cache.tvMap.set(request.theTvDbId, request);
|
||||||
|
}
|
||||||
|
if (request.theMovieDbId) {
|
||||||
|
this.cache.tvMap.set(request.theMovieDbId, request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||||
|
// Don't throw error, continue with stale cache if available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all movie requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
|
* @returns {Promise<Array>} Array of movie request objects
|
||||||
|
*/
|
||||||
|
async getMovieRequests(force = false) {
|
||||||
|
await this.refreshCache(force);
|
||||||
|
return this.cache.movieRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all TV requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
|
* @returns {Promise<Array>} Array of TV request objects
|
||||||
|
*/
|
||||||
|
async getTvRequests(force = false) {
|
||||||
|
await this.refreshCache(force);
|
||||||
|
return this.cache.tvRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find movie request by external ID
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID
|
||||||
|
* @param {string} imdbId - IMDB ID (optional fallback)
|
||||||
|
* @returns {Promise<Object|null>} Request object or null if not found
|
||||||
|
*/
|
||||||
|
async findMovieRequest(tmdbId, imdbId = null) {
|
||||||
|
await this.refreshCache();
|
||||||
|
|
||||||
|
// Try TMDB ID first
|
||||||
|
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||||
|
return this.cache.movieMap.get(tmdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try IMDB ID as fallback
|
||||||
|
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||||
|
return this.cache.movieMap.get(imdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find TV request by external ID
|
||||||
|
* @param {string} tvdbId - TheTVDB ID
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
|
||||||
|
* @returns {Promise<Object|null>} Request object or null if not found
|
||||||
|
*/
|
||||||
|
async findTvRequest(tvdbId, tmdbId = null) {
|
||||||
|
await this.refreshCache();
|
||||||
|
|
||||||
|
// Try TVDB ID first
|
||||||
|
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||||
|
return this.cache.tvMap.get(tvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TMDB ID as fallback
|
||||||
|
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||||
|
return this.cache.tvMap.get(tmdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for movie by external ID (for fallback when no request found)
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID
|
||||||
|
* @param {string} imdbId - IMDB ID (optional fallback)
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchMovie(tmdbId, imdbId = null) {
|
||||||
|
if (tmdbId) {
|
||||||
|
const result = await this.client.searchMovieByTmdbId(tmdbId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId) {
|
||||||
|
const result = await this.client.searchMovieByImdbId(imdbId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for TV show by external ID (for fallback when no request found)
|
||||||
|
* @param {string} tvdbId - TheTVDB ID
|
||||||
|
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
|
||||||
|
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||||
|
*/
|
||||||
|
async searchTv(tvdbId, tmdbId = null) {
|
||||||
|
if (tvdbId) {
|
||||||
|
const result = await this.client.searchTvByTvdbId(tvdbId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
const result = await this.client.searchTvByTmdbId(tmdbId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
* @returns {Object} Cache statistics
|
||||||
|
*/
|
||||||
|
getCacheStats() {
|
||||||
|
return {
|
||||||
|
movieRequests: this.cache.movieRequests.length,
|
||||||
|
tvRequests: this.cache.tvRequests.length,
|
||||||
|
movieMapSize: this.cache.movieMap.size,
|
||||||
|
tvMapSize: this.cache.tvMap.size,
|
||||||
|
lastFetch: this.cache.lastFetch,
|
||||||
|
age: Date.now() - this.cache.lastFetch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OmbiRetriever;
|
||||||
@@ -37,22 +37,42 @@ class PollingRadarrRetriever extends ArrRetriever {
|
|||||||
* @returns {Promise<Object>} Queue object with records array
|
* @returns {Promise<Object>} Queue object with records array
|
||||||
*/
|
*/
|
||||||
async getQueue() {
|
async getQueue() {
|
||||||
try {
|
const instanceName = this.name;
|
||||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
let page = 1;
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
let allRecords = [];
|
||||||
params: { includeMovie: true }
|
let responseData = null;
|
||||||
});
|
|
||||||
return response.data;
|
do {
|
||||||
} catch (error) {
|
try {
|
||||||
logToFile(`[PollingRadarrRetriever] ${this.id} queue error: ${error.message}`);
|
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||||
return { records: [] };
|
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
|
* Get history from Radarr instance
|
||||||
* @param {Object} options - Optional parameters for history fetch
|
* @param {Object} options - Optional parameters for history fetch
|
||||||
* @param {number} [options.pageSize=10] - Number of records to 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.sortKey] - Field to sort by
|
||||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||||
* @param {boolean} [options.includeMovie=true] - Include movie data
|
* @param {boolean} [options.includeMovie=true] - Include movie data
|
||||||
@@ -61,15 +81,22 @@ class PollingRadarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getHistory(options = {}) {
|
async getHistory(options = {}) {
|
||||||
const {
|
const {
|
||||||
pageSize = 10,
|
pageSize = 100,
|
||||||
|
maxPages = 1,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDir,
|
sortDir,
|
||||||
includeMovie = true,
|
includeMovie = true,
|
||||||
startDate
|
startDate
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
try {
|
const instanceName = this.name;
|
||||||
|
let page = 1;
|
||||||
|
let allRecords = [];
|
||||||
|
let responseData = null;
|
||||||
|
|
||||||
|
do {
|
||||||
const params = {
|
const params = {
|
||||||
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
includeMovie
|
includeMovie
|
||||||
};
|
};
|
||||||
@@ -78,15 +105,29 @@ class PollingRadarrRetriever extends ArrRetriever {
|
|||||||
if (sortDir) params.sortDir = sortDir;
|
if (sortDir) params.sortDir = sortDir;
|
||||||
if (startDate) params.startDate = startDate;
|
if (startDate) params.startDate = startDate;
|
||||||
|
|
||||||
const response = await axios.get(`${this.url}/api/v3/history`, {
|
try {
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
const response = await axios.get(`${this.url}/api/v3/history`, {
|
||||||
params
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
});
|
params
|
||||||
return response.data;
|
});
|
||||||
} catch (error) {
|
responseData = response.data;
|
||||||
logToFile(`[PollingRadarrRetriever] ${this.id} history error: ${error.message}`);
|
} catch (error) {
|
||||||
return { records: [] };
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,22 +37,42 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
* @returns {Promise<Object>} Queue object with records array
|
* @returns {Promise<Object>} Queue object with records array
|
||||||
*/
|
*/
|
||||||
async getQueue() {
|
async getQueue() {
|
||||||
try {
|
const instanceName = this.name;
|
||||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
let page = 1;
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
let allRecords = [];
|
||||||
params: { includeSeries: true, includeEpisode: true }
|
let responseData = null;
|
||||||
});
|
|
||||||
return response.data;
|
do {
|
||||||
} catch (error) {
|
try {
|
||||||
logToFile(`[PollingSonarrRetriever] ${this.id} queue error: ${error.message}`);
|
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||||
return { records: [] };
|
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
|
* Get history from Sonarr instance
|
||||||
* @param {Object} options - Optional parameters for history fetch
|
* @param {Object} options - Optional parameters for history fetch
|
||||||
* @param {number} [options.pageSize=10] - Number of records to 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.sortKey] - Field to sort by
|
||||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||||
* @param {boolean} [options.includeSeries=true] - Include series data
|
* @param {boolean} [options.includeSeries=true] - Include series data
|
||||||
@@ -62,7 +82,8 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getHistory(options = {}) {
|
async getHistory(options = {}) {
|
||||||
const {
|
const {
|
||||||
pageSize = 10,
|
pageSize = 100,
|
||||||
|
maxPages = 1,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDir,
|
sortDir,
|
||||||
includeSeries = true,
|
includeSeries = true,
|
||||||
@@ -70,8 +91,14 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
startDate
|
startDate
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
try {
|
const instanceName = this.name;
|
||||||
|
let page = 1;
|
||||||
|
let allRecords = [];
|
||||||
|
let responseData = null;
|
||||||
|
|
||||||
|
do {
|
||||||
const params = {
|
const params = {
|
||||||
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
includeSeries,
|
includeSeries,
|
||||||
includeEpisode
|
includeEpisode
|
||||||
@@ -81,15 +108,29 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
if (sortDir) params.sortDir = sortDir;
|
if (sortDir) params.sortDir = sortDir;
|
||||||
if (startDate) params.startDate = startDate;
|
if (startDate) params.startDate = startDate;
|
||||||
|
|
||||||
const response = await axios.get(`${this.url}/api/v3/history`, {
|
try {
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
const response = await axios.get(`${this.url}/api/v3/history`, {
|
||||||
params
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
});
|
params
|
||||||
return response.data;
|
});
|
||||||
} catch (error) {
|
responseData = response.data;
|
||||||
logToFile(`[PollingSonarrRetriever] ${this.id} history error: ${error.message}`);
|
} catch (error) {
|
||||||
return { records: [] };
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
if (this.fallbackThisCycle) {
|
if (this.fallbackThisCycle) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
|
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
|
||||||
const torrents = await this.getTorrentsLegacy();
|
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));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +175,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
this.fallbackThisCycle = true;
|
this.fallbackThisCycle = true;
|
||||||
try {
|
try {
|
||||||
const torrents = await this.getTorrentsLegacy();
|
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));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
||||||
|
|||||||
@@ -53,19 +53,49 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
const queueData = queueResponse.data;
|
const queueData = queueResponse.data;
|
||||||
const historyData = historyResponse.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 = [];
|
const downloads = [];
|
||||||
|
|
||||||
// Process active queue items
|
// Process active queue items
|
||||||
if (queueData.queue && queueData.queue.slots) {
|
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) {
|
for (const slot of queueData.queue.slots) {
|
||||||
downloads.push(this.normalizeDownload(slot, 'queue'));
|
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)
|
// Process recent history items (last 10)
|
||||||
if (historyData.history && historyData.history.slots) {
|
if (historyData.history && historyData.history.slots) {
|
||||||
for (const slot of historyData.history.slots) {
|
for (const slot of historyData.history.slots) {
|
||||||
downloads.push(this.normalizeDownload(slot, 'history'));
|
downloads.push(this.normalizeDownload(slot, 'history', 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +132,10 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeDownload(slot, source) {
|
normalizeDownload(slot, source, speed) {
|
||||||
const isHistory = source === 'history';
|
const isHistory = source === 'history';
|
||||||
|
const finalSpeed = speed !== undefined ? speed : (slot.kbpersec ? parseFloat(slot.kbpersec) * 1024 : 0);
|
||||||
|
|
||||||
// Map SABnzbd statuses to normalized status
|
// Map SABnzbd statuses to normalized status
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
'Downloading': 'Downloading',
|
'Downloading': 'Downloading',
|
||||||
@@ -126,10 +157,15 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
let size = 0;
|
let size = 0;
|
||||||
|
|
||||||
if (slot.mb && slot.mbleft !== undefined) {
|
const hasMb = slot.mb !== undefined && slot.mb !== null;
|
||||||
size = slot.mb * 1024 * 1024; // Convert MB to bytes
|
const hasMbLeft = slot.mbleft !== undefined && slot.mbleft !== null;
|
||||||
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
|
const mbValue = hasMb ? parseFloat(slot.mb) : 0;
|
||||||
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 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) {
|
} else if (slot.size) {
|
||||||
// Try to parse size string (e.g., "1.5 GB")
|
// Try to parse size string (e.g., "1.5 GB")
|
||||||
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
|
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
|
||||||
@@ -164,10 +200,10 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
progress: Math.round(progress),
|
progress: Math.round(progress),
|
||||||
size: Math.round(size),
|
size: Math.round(size),
|
||||||
downloaded: Math.round(downloaded),
|
downloaded: Math.round(downloaded),
|
||||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
speed: finalSpeed,
|
||||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||||
category: slot.cat || undefined,
|
category: slot.cat || undefined,
|
||||||
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
|
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
|
||||||
savePath: slot.final_name || undefined,
|
savePath: slot.final_name || undefined,
|
||||||
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
||||||
arrQueueId: arrInfo.queueId,
|
arrQueueId: arrInfo.queueId,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const crypto = require('crypto');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const swaggerUi = require('swagger-ui-express');
|
||||||
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
|
const YAML = require('yamljs');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
require('./utils/loadSecrets')();
|
require('./utils/loadSecrets')();
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
@@ -82,9 +85,11 @@ const sonarrRoutes = require('./routes/sonarr');
|
|||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const statusRoutes = require('./routes/status');
|
||||||
const historyRoutes = require('./routes/history');
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
|
const ombiRoutes = require('./routes/ombi');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
@@ -112,6 +117,23 @@ if (process.env.EMBY_URL) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// 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, 'index.js')
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
@@ -184,6 +206,7 @@ const apiLimiter = rateLimit({
|
|||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
max: 300, // 300 requests per IP per window (generous for polling)
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,10 +220,71 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
// Health / readiness endpoints (no auth, no rate-limit)
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
// Used by Docker HEALTHCHECK and orchestrators.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /health:
|
||||||
|
* get:
|
||||||
|
* tags: [Health]
|
||||||
|
* summary: Health check
|
||||||
|
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
||||||
|
* security: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Server is healthy
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "ok"
|
||||||
|
* uptime:
|
||||||
|
* type: number
|
||||||
|
* description: Server uptime in seconds
|
||||||
|
* example: 3600.5
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* description: sofarr version
|
||||||
|
* example: "1.6.0"
|
||||||
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ready:
|
||||||
|
* get:
|
||||||
|
* tags: [Health]
|
||||||
|
* summary: Readiness check
|
||||||
|
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
||||||
|
* security: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Server is ready
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "ready"
|
||||||
|
* '503':
|
||||||
|
* description: Server not ready
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* status:
|
||||||
|
* type: string
|
||||||
|
* example: "not ready"
|
||||||
|
* reason:
|
||||||
|
* type: string
|
||||||
|
* example: "EMBY_URL not configured"
|
||||||
|
*/
|
||||||
app.get('/ready', (req, res) => {
|
app.get('/ready', (req, res) => {
|
||||||
// Confirm critical config is present
|
// Confirm critical config is present
|
||||||
const ready = !!(process.env.EMBY_URL);
|
const ready = !!(process.env.EMBY_URL);
|
||||||
@@ -211,6 +295,35 @@ app.get('/ready', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Swagger UI - publicly accessible API documentation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
||||||
|
customSiteTitle: 'sofarr API Documentation',
|
||||||
|
customCss: '.swagger-ui .topbar { display: none }',
|
||||||
|
customJs: [
|
||||||
|
'/swagger-auth-banner.js'
|
||||||
|
],
|
||||||
|
swaggerOptions: {
|
||||||
|
url: '/api/swagger.json'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
||||||
|
app.get('/api/swagger.json', (req, res) => {
|
||||||
|
// Clone the spec to avoid modifying the original
|
||||||
|
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
||||||
|
|
||||||
|
// Replace the server URL with the current request's origin
|
||||||
|
if (specCopy.servers && specCopy.servers.length > 0) {
|
||||||
|
const protocol = req.protocol;
|
||||||
|
const host = req.get('host');
|
||||||
|
specCopy.servers[0].url = `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(specCopy);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Static files — served before API routes
|
// Static files — served before API routes
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
@@ -261,7 +374,9 @@ app.use('/api/sabnzbd', sabnzbdRoutes);
|
|||||||
app.use('/api/sonarr', sonarrRoutes);
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
|
app.use('/api/ombi', ombiRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ function verifyCsrf(req, res, next) {
|
|||||||
return res.status(403).json({ error: 'CSRF token missing' });
|
return res.status(403).json({ error: 'CSRF token missing' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
const a = Buffer.from(cookieToken);
|
||||||
if (cookieToken.length !== headerToken.length) {
|
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' });
|
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)) {
|
if (!require('crypto').timingSafeEqual(a, b)) {
|
||||||
return res.status(403).json({ error: 'CSRF token invalid' });
|
return res.status(403).json({ error: 'CSRF token invalid' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,159 @@ const loginLimiter = rateLimit({
|
|||||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
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) => {
|
router.post('/login', loginLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password, rememberMe } = req.body;
|
const { username, password, rememberMe } = req.body;
|
||||||
@@ -129,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) => {
|
router.get('/me', (req, res) => {
|
||||||
const user = parseSessionCookie(req);
|
const user = parseSessionCookie(req);
|
||||||
if (!user) return res.json({ authenticated: false });
|
if (!user) return res.json({ authenticated: false });
|
||||||
@@ -139,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) => {
|
router.get('/csrf', (req, res) => {
|
||||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
res.cookie('csrf_token', csrfToken, {
|
res.cookie('csrf_token', csrfToken, {
|
||||||
@@ -152,7 +426,84 @@ router.get('/csrf', (req, res) => {
|
|||||||
res.json({ csrfToken });
|
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) => {
|
router.post('/logout', async (req, res) => {
|
||||||
const user = parseSessionCookie(req);
|
const user = parseSessionCookie(req);
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -5,9 +5,26 @@ const router = express.Router();
|
|||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
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);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Get active sessions
|
// GET /api/emby/sessions - list active Emby sessions
|
||||||
router.get('/sessions', async (req, res) => {
|
router.get('/sessions', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||||
@@ -19,19 +36,24 @@ router.get('/sessions', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user by ID
|
/**
|
||||||
router.get('/users/:id', async (req, res) => {
|
* @openapi
|
||||||
try {
|
* /api/emby/users:
|
||||||
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
* get:
|
||||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
* tags: [Emby]
|
||||||
});
|
* summary: Get all Emby users
|
||||||
res.json(response.data);
|
* description: Proxy to Emby's users list endpoint. Requires authentication.
|
||||||
} catch (error) {
|
* security:
|
||||||
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
* - CookieAuth: []
|
||||||
}
|
* responses:
|
||||||
});
|
* '200':
|
||||||
|
* description: Users list from Emby
|
||||||
// Get all users
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
*/
|
||||||
|
// GET /api/emby/users - list all users
|
||||||
router.get('/users', async (req, res) => {
|
router.get('/users', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||||
@@ -43,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) => {
|
router.get('/session/:sessionId/user', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||||
|
|||||||
@@ -1,119 +1,14 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const axios = require('axios');
|
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicate history items so that for each unique content item (episode or
|
* Deduplicate history items so that for each unique content item (episode or
|
||||||
@@ -184,49 +79,139 @@ function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSonarrLink(series) {
|
|
||||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
|
||||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRadarrLink(movie) {
|
|
||||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
|
||||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/history/recent
|
* @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).
|
||||||
*
|
*
|
||||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
* **Authentication:** Requires valid `emby_user` cookie.
|
||||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
|
||||||
* (default 7, overridable via env or ?days= query param).
|
|
||||||
*
|
*
|
||||||
* Response shape:
|
* **Filtering:**
|
||||||
* {
|
* - Non-admin users: Only see history items tagged with their username
|
||||||
* user: string,
|
* - Admin users: Can see all history by setting query parameter `showAll=true`
|
||||||
* isAdmin: boolean,
|
* - Date range: Configurable via `?days=` query parameter (default: 7, max: 90)
|
||||||
* days: number,
|
|
||||||
* history: HistoryItem[]
|
|
||||||
* }
|
|
||||||
*
|
*
|
||||||
* HistoryItem shape:
|
* **Deduplication Rules:**
|
||||||
* {
|
* For each unique content item (episode or movie), only the most recent record is shown:
|
||||||
* type: 'series'|'movie',
|
* - If the most recent event is "imported" → show it; suppress older failures
|
||||||
* outcome: 'imported'|'failed',
|
* - If the most recent event is "failed" and the item has a file on disk → show with `availableForUpgrade=true`
|
||||||
* title: string, // sourceTitle from arr record
|
* - If the most recent event is "failed" and no file exists → show normally
|
||||||
* seriesName?: string, // series.title (Sonarr)
|
*
|
||||||
* movieName?: string, // movie.title (Radarr)
|
* **Event Classification:**
|
||||||
* coverArt: string|null,
|
* - Sonarr: DownloadFolderImported, ImportFailed → included
|
||||||
* completedAt: string, // ISO date string from arr record
|
* - Radarr: DownloadFolderImported, ImportFailed → included
|
||||||
* quality: string|null,
|
* - Other event types (Rename, Health, etc.) → excluded
|
||||||
* instanceName: string, // arr instance name
|
*
|
||||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
* **Response Structure:**
|
||||||
* allTags: string[],
|
* - `type`: "series" or "movie"
|
||||||
* matchedUserTag: string|null,
|
* - `outcome`: "imported" or "failed"
|
||||||
* // admin-only:
|
* - `title`: Source title from *arr record
|
||||||
* arrRecordId?: number,
|
* - `seriesName`/`movieName`: Friendly media title
|
||||||
* failureMessage?: string,
|
* - `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) => {
|
router.get('/recent', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -245,10 +230,13 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
|
||||||
|
|
||||||
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
|
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
|
||||||
fetchSonarrHistory(since),
|
fetchSonarrHistory(since),
|
||||||
fetchRadarrHistory(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,
|
// Build tag maps from the cached poll data where available,
|
||||||
@@ -269,8 +257,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
const series = record.series;
|
const series = record.series;
|
||||||
if (!series) continue;
|
if (!series) continue;
|
||||||
|
|
||||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
const hasAnyTag = allTags.length > 0;
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
|
||||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||||
@@ -285,20 +273,23 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
outcome,
|
outcome,
|
||||||
title: sourceTitle,
|
title: sourceTitle,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
episodes: DownloadAssembler.gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||||
coverArt: getCoverArt(series),
|
coverArt: DownloadAssembler.getCoverArt(series),
|
||||||
completedAt: record.date,
|
completedAt: record.date,
|
||||||
quality,
|
quality,
|
||||||
instanceName: record._instanceName || null,
|
instanceName: record._instanceName || null,
|
||||||
arrLink: getSonarrLink(series),
|
arrLink: DownloadAssembler.getSonarrLink(series),
|
||||||
|
ombiLink: DownloadAssembler.getOmbiDetailsLink(series, 'series', ombiBaseUrl),
|
||||||
|
ombiTooltip: 'View in Ombi',
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
_contentId: record.episodeId != null ? record.episodeId : null
|
_contentId: record.episodeId != null ? record.episodeId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
item.arrRecordId = record.id;
|
item.arrRecordId = record.id;
|
||||||
|
item.arrType = 'sonarr';
|
||||||
if (outcome === 'failed' && record.data && record.data.message) {
|
if (outcome === 'failed' && record.data && record.data.message) {
|
||||||
item.failureMessage = record.data.message;
|
item.failureMessage = record.data.message;
|
||||||
}
|
}
|
||||||
@@ -319,8 +310,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
const movie = record.movie;
|
const movie = record.movie;
|
||||||
if (!movie) continue;
|
if (!movie) continue;
|
||||||
|
|
||||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
const hasAnyTag = allTags.length > 0;
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
|
||||||
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||||
@@ -334,19 +325,22 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
outcome,
|
outcome,
|
||||||
title: record.sourceTitle || record.title || movie.title,
|
title: record.sourceTitle || record.title || movie.title,
|
||||||
movieName: movie.title,
|
movieName: movie.title,
|
||||||
coverArt: getCoverArt(movie),
|
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||||
completedAt: record.date,
|
completedAt: record.date,
|
||||||
quality,
|
quality,
|
||||||
instanceName: record._instanceName || null,
|
instanceName: record._instanceName || null,
|
||||||
arrLink: getRadarrLink(movie),
|
arrLink: DownloadAssembler.getRadarrLink(movie),
|
||||||
|
ombiLink: DownloadAssembler.getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
|
||||||
|
ombiTooltip: 'View in Ombi',
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
_contentId: record.movieId != null ? record.movieId : null
|
_contentId: record.movieId != null ? record.movieId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
item.arrRecordId = record.id;
|
item.arrRecordId = record.id;
|
||||||
|
item.arrType = 'radarr';
|
||||||
if (outcome === 'failed' && record.data && record.data.message) {
|
if (outcome === 'failed' && record.data && record.data.message) {
|
||||||
item.failureMessage = record.data.message;
|
item.failureMessage = record.data.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const express = require('express');
|
||||||
|
const { logToFile } = require('../utils/logger');
|
||||||
|
const cache = require('../utils/cache');
|
||||||
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
||||||
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/ombi/requests:
|
||||||
|
* get:
|
||||||
|
* tags: [Ombi]
|
||||||
|
* summary: Get Ombi requests
|
||||||
|
* description: |
|
||||||
|
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
|
||||||
|
* (filtered by Emby user mapping), while admins see all requests.
|
||||||
|
*
|
||||||
|
* Supports server-side filtering by media type, request status, title search,
|
||||||
|
* and sorting by requested date or title.
|
||||||
|
*
|
||||||
|
* **Authentication:** Requires cookie authentication.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - name: type
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* enum: [movie, tv, all]
|
||||||
|
* default: [all]
|
||||||
|
* description: Filter by media type. Omit or use `all` for both.
|
||||||
|
* style: form
|
||||||
|
* explode: true
|
||||||
|
* - name: status
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* enum: [pending, approved, available, denied]
|
||||||
|
* description: Filter by request status. Omit for all statuses.
|
||||||
|
* style: form
|
||||||
|
* explode: true
|
||||||
|
* - name: sort
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc]
|
||||||
|
* default: requestedDate_desc
|
||||||
|
* description: Sort mode.
|
||||||
|
* - name: search
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Case-insensitive substring match on title.
|
||||||
|
* - name: showAll
|
||||||
|
* in: query
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: ['true', 'false']
|
||||||
|
* description: Admin only. Show all users' requests.
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Ombi requests retrieved successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* user:
|
||||||
|
* type: string
|
||||||
|
* example: "username"
|
||||||
|
* isAdmin:
|
||||||
|
* type: boolean
|
||||||
|
* example: false
|
||||||
|
* showAll:
|
||||||
|
* type: boolean
|
||||||
|
* example: false
|
||||||
|
* requests:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* movie:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/OmbiRequest'
|
||||||
|
* tv:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/OmbiRequest'
|
||||||
|
* total:
|
||||||
|
* type: integer
|
||||||
|
* example: 5
|
||||||
|
* '401':
|
||||||
|
* description: Unauthorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
router.get('/requests', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
const isAdmin = user.isAdmin;
|
||||||
|
const username = user.name;
|
||||||
|
const showAll = isAdmin && req.query.showAll === 'true';
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
|
// initialize() is idempotent - cheap no-op if already initialized
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
|
// Filter by user if not admin or if showAll is false
|
||||||
|
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||||
|
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
|
||||||
|
|
||||||
|
// Tag with mediaType and flatten for filtering/sorting
|
||||||
|
const allRequests = [
|
||||||
|
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
|
||||||
|
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Parse query params
|
||||||
|
let types = req.query.type;
|
||||||
|
let statuses = req.query.status;
|
||||||
|
const sort = req.query.sort || 'requestedDate_desc';
|
||||||
|
const search = req.query.search || '';
|
||||||
|
|
||||||
|
// Normalise to arrays
|
||||||
|
if (typeof types === 'string') types = [types];
|
||||||
|
if (typeof statuses === 'string') statuses = [statuses];
|
||||||
|
|
||||||
|
// Apply filters and sorting
|
||||||
|
const filtered = applyRequestFilters(allRequests, { types, statuses, sort, search });
|
||||||
|
|
||||||
|
// Split back into movie/tv
|
||||||
|
const movie = filtered.filter(r => r.mediaType === 'movie');
|
||||||
|
const tv = filtered.filter(r => r.mediaType === 'tv');
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: username,
|
||||||
|
isAdmin,
|
||||||
|
showAll,
|
||||||
|
requests: { movie, tv },
|
||||||
|
total
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Error fetching requests: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Ombi requests' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/ombi/webhook/enable:
|
||||||
|
* post:
|
||||||
|
* tags: [Ombi]
|
||||||
|
* summary: Enable Ombi webhook
|
||||||
|
* description: |
|
||||||
|
* Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
|
||||||
|
*
|
||||||
|
* **Authentication:** Requires cookie authentication.
|
||||||
|
* **CSRF:** Requires X-CSRF-Token header matching csrf_token cookie.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: false
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Webhook enabled successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* success:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* webhookUrl:
|
||||||
|
* type: string
|
||||||
|
* example: "https://sofarr.example.com/api/webhook/ombi"
|
||||||
|
* applicationToken:
|
||||||
|
* type: string
|
||||||
|
* example: "your-ombi-api-key"
|
||||||
|
* '400':
|
||||||
|
* description: Invalid request or missing configuration
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* '401':
|
||||||
|
* description: Unauthorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
|
if (!sofarrBaseUrl) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
|
}
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
if (ombiInstances.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Ombi not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInst = ombiInstances[0];
|
||||||
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
|
// Call Ombi API to register webhook
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Get existing settings to retrieve the database ID
|
||||||
|
const currentRes = await axios.get(
|
||||||
|
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'ApiKey': ombiInst.apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch(err => {
|
||||||
|
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentConfig = currentRes.data || {};
|
||||||
|
const settingsId = currentConfig.id || 0;
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||||
|
{
|
||||||
|
id: settingsId,
|
||||||
|
enabled: true,
|
||||||
|
webhookUrl: webhookUrl,
|
||||||
|
applicationToken: ombiInst.apiKey
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'ApiKey': ombiInst.apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logToFile(`[Ombi] Webhook enabled: ${webhookUrl}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
webhookUrl: webhookUrl,
|
||||||
|
applicationToken: ombiInst.apiKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Error enabling webhook: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to enable Ombi webhook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/ombi/webhook/status:
|
||||||
|
* get:
|
||||||
|
* tags: [Ombi]
|
||||||
|
* summary: Get Ombi webhook status
|
||||||
|
* description: |
|
||||||
|
* Returns the current Ombi webhook configuration status and metrics.
|
||||||
|
*
|
||||||
|
* **Authentication:** Requires cookie authentication.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Webhook status retrieved successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* webhookUrl:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* example: "https://sofarr.example.com/api/webhook/ombi"
|
||||||
|
* applicationToken:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* example: "your-ombi-api-key"
|
||||||
|
* triggers:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* requestAvailable:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* requestApproved:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* requestDeclined:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* requestPending:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* requestProcessing:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* stats:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* eventsReceived:
|
||||||
|
* type: integer
|
||||||
|
* example: 10
|
||||||
|
* pollsSkipped:
|
||||||
|
* type: integer
|
||||||
|
* example: 5
|
||||||
|
* lastWebhookTimestamp:
|
||||||
|
* type: integer
|
||||||
|
* example: 1716326400000
|
||||||
|
* '401':
|
||||||
|
* description: Unauthorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
router.get('/webhook/status', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
|
// Webhooks require SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET to be configured
|
||||||
|
if (!sofarrBaseUrl || !webhookSecret) {
|
||||||
|
return res.json({
|
||||||
|
enabled: false,
|
||||||
|
webhookUrl: null,
|
||||||
|
applicationToken: null,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: false,
|
||||||
|
requestApproved: false,
|
||||||
|
requestDeclined: false,
|
||||||
|
requestPending: false,
|
||||||
|
requestProcessing: false
|
||||||
|
},
|
||||||
|
stats: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
if (ombiInstances.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
enabled: false,
|
||||||
|
webhookUrl: null,
|
||||||
|
applicationToken: null,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: false,
|
||||||
|
requestApproved: false,
|
||||||
|
requestDeclined: false,
|
||||||
|
requestPending: false,
|
||||||
|
requestProcessing: false
|
||||||
|
},
|
||||||
|
stats: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInst = ombiInstances[0];
|
||||||
|
|
||||||
|
// Call Ombi API to get webhook status
|
||||||
|
const axios = require('axios');
|
||||||
|
const response = await axios.get(
|
||||||
|
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'ApiKey': ombiInst.apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const webhookConfig = response.data;
|
||||||
|
|
||||||
|
// Get webhook metrics from cache
|
||||||
|
const metrics = cache.getWebhookMetrics(ombiInst.url);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: webhookConfig.enabled || false,
|
||||||
|
webhookUrl: webhookConfig.webhookUrl || null,
|
||||||
|
applicationToken: webhookConfig.applicationToken || null,
|
||||||
|
// Note: Ombi may support per-trigger toggles, but we currently treat
|
||||||
|
// them as all-on or all-off based on webhookConfig.enabled
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: webhookConfig.enabled || false,
|
||||||
|
requestApproved: webhookConfig.enabled || false,
|
||||||
|
requestDeclined: webhookConfig.enabled || false,
|
||||||
|
requestPending: webhookConfig.enabled || false,
|
||||||
|
requestProcessing: webhookConfig.enabled || false
|
||||||
|
},
|
||||||
|
stats: metrics ? {
|
||||||
|
eventsReceived: metrics.eventsReceived || 0,
|
||||||
|
pollsSkipped: metrics.pollsSkipped || 0,
|
||||||
|
lastWebhookTimestamp: metrics.lastWebhookTimestamp || null
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Error fetching webhook status: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Ombi webhook status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/ombi/webhook/test:
|
||||||
|
* post:
|
||||||
|
* tags: [Ombi]
|
||||||
|
* summary: Test Ombi webhook
|
||||||
|
* description: |
|
||||||
|
* Sends a test webhook event to the Sofarr Ombi webhook endpoint.
|
||||||
|
*
|
||||||
|
* **Authentication:** Requires cookie authentication and CSRF token.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* - CsrfToken: []
|
||||||
|
* requestBody:
|
||||||
|
* required: false
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Test webhook sent successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* success:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* '400':
|
||||||
|
* description: Invalid request or missing configuration
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
* '401':
|
||||||
|
* description: Unauthorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
|
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
|
if (!sofarrBaseUrl) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
|
}
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
if (ombiInstances.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Ombi not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ombiInst = ombiInstances[0];
|
||||||
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
|
// Simulate a test webhook event
|
||||||
|
const axios = require('axios');
|
||||||
|
await axios.post(webhookUrl, {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 0,
|
||||||
|
requestedUser: 'test',
|
||||||
|
title: 'Test Request',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending'
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Error testing webhook: ${error.message}`);
|
||||||
|
res.status(500).json({ error: 'Failed to test Ombi webhook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -4,15 +4,52 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
||||||
|
|
||||||
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
|
function getFirstRadarrInstance() {
|
||||||
|
const instances = getRadarrInstances();
|
||||||
|
if (!instances || instances.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return instances[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/radarr/queue:
|
||||||
|
* get:
|
||||||
|
* tags: [Radarr]
|
||||||
|
* summary: Get Radarr queue
|
||||||
|
* description: Proxy to Radarr's queue endpoint. Requires authentication and CSRF token.
|
||||||
|
* security:
|
||||||
|
* - CookieAuth: []
|
||||||
|
* - CsrfToken: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Queue data from Radarr
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* '500':
|
||||||
|
* description: Proxy error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Get queue
|
// Get queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
|
const response = await axios.get(`${instance.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -20,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
|
// Get history
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
|
const response = await axios.get(`${instance.url}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
|
headers: { 'X-Api-Key': instance.apiKey },
|
||||||
params: { pageSize: req.query.pageSize || 50 }
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
@@ -35,9 +100,13 @@ router.get('/history', async (req, res) => {
|
|||||||
|
|
||||||
// Get movie details
|
// Get movie details
|
||||||
router.get('/movies/:id', async (req, res) => {
|
router.get('/movies/:id', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
const response = await axios.get(`${instance.url}/api/v3/movie/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -47,9 +116,13 @@ router.get('/movies/:id', async (req, res) => {
|
|||||||
|
|
||||||
// Get all movies with tags
|
// Get all movies with tags
|
||||||
router.get('/movies', async (req, res) => {
|
router.get('/movies', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
|
const response = await axios.get(`${instance.url}/api/v3/movie`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -57,24 +130,63 @@ 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)
|
// Notification proxy routes (Phase 3)
|
||||||
// GET /api/radarr/notifications - list all notifications
|
// GET /api/radarr/notifications - list all notifications
|
||||||
router.get('/notifications', async (req, res) => {
|
router.get('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Radarr] Failed to fetch notifications:', error.message);
|
||||||
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/radarr/notifications/:id - get specific notification
|
// GET /api/radarr/notifications/:id - get specific notification
|
||||||
router.get('/notifications/:id', async (req, res) => {
|
router.get('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,9 +196,13 @@ router.get('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/radarr/notifications - create notification
|
// POST /api/radarr/notifications - create notification
|
||||||
router.post('/notifications', async (req, res) => {
|
router.post('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,9 +212,13 @@ router.post('/notifications', async (req, res) => {
|
|||||||
|
|
||||||
// PUT /api/radarr/notifications/:id - update notification
|
// PUT /api/radarr/notifications/:id - update notification
|
||||||
router.put('/notifications/:id', async (req, res) => {
|
router.put('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,9 +228,13 @@ router.put('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// DELETE /api/radarr/notifications/:id - delete notification
|
// DELETE /api/radarr/notifications/:id - delete notification
|
||||||
router.delete('/notifications/:id', async (req, res) => {
|
router.delete('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,21 +244,34 @@ router.delete('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/radarr/notifications/test - test notification
|
// POST /api/radarr/notifications/test - test notification
|
||||||
router.post('/notifications/test', async (req, res) => {
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} 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) });
|
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/radarr/notifications/schema - get notification schema
|
// GET /api/radarr/notifications/schema - get notification schema
|
||||||
router.get('/notifications/schema', async (req, res) => {
|
router.get('/notifications/schema', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -144,6 +281,10 @@ router.get('/notifications/schema', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
@@ -158,8 +299,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
@@ -169,36 +310,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
configContract: 'WebhookSettings',
|
configContract: 'WebhookSettings',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'url', value: webhookUrl },
|
{ name: 'url', value: webhookUrl },
|
||||||
{ name: 'method', value: 'POST' },
|
{ name: 'method', value: 1 },
|
||||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
],
|
],
|
||||||
onGrab: true,
|
onGrab: true,
|
||||||
onDownload: true,
|
onDownload: true,
|
||||||
onImport: true,
|
|
||||||
onUpgrade: true,
|
onUpgrade: true,
|
||||||
|
onImport: true,
|
||||||
onRename: false,
|
onRename: false,
|
||||||
onHealthIssue: false,
|
onHealthIssue: false,
|
||||||
onApplicationUpdate: false
|
onApplicationUpdate: false,
|
||||||
|
onManualInteractionRequired: false
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingNotification) {
|
if (existingNotification) {
|
||||||
// Update existing notification
|
// Update existing notification
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
|
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||||
{ ...notificationPayload, id: existingNotification.id },
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} else {
|
} else {
|
||||||
// Create new notification
|
// Create new notification
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${process.env.RADARR_URL}/api/v3/notification`,
|
`${instance.url}/api/v3/notification`,
|
||||||
notificationPayload,
|
notificationPayload,
|
||||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Radarr] Failed to configure webhook:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Radarr] Response status:', error.response.status);
|
||||||
|
console.error('[Radarr] Response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,53 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
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);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Get current queue
|
// GET /api/sabnzbd/queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
|
const instance = getFirstSABnzbdInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'SABnzbd not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
const response = await axios.get(`${instance.url}/api`, {
|
||||||
params: {
|
params: {
|
||||||
mode: 'queue',
|
mode: 'queue',
|
||||||
apikey: process.env.SABNZBD_API_KEY,
|
apikey: instance.apiKey,
|
||||||
output: 'json'
|
output: 'json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -23,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) => {
|
router.get('/history', async (req, res) => {
|
||||||
|
const instance = getFirstSABnzbdInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'SABnzbd not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
const response = await axios.get(`${instance.url}/api`, {
|
||||||
params: {
|
params: {
|
||||||
mode: 'history',
|
mode: 'history',
|
||||||
apikey: process.env.SABNZBD_API_KEY,
|
apikey: instance.apiKey,
|
||||||
output: 'json',
|
output: 'json',
|
||||||
limit: req.query.limit || 50
|
limit: req.query.limit || 50
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,52 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
||||||
|
|
||||||
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
|
function getFirstSonarrInstance() {
|
||||||
|
const instances = getSonarrInstances();
|
||||||
|
if (!instances || instances.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return instances[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /api/sonarr/queue:
|
||||||
|
* get:
|
||||||
|
* tags: [Sonarr]
|
||||||
|
* summary: Get Sonarr queue
|
||||||
|
* description: Proxy to Sonarr's queue endpoint. Requires authentication and CSRF token.
|
||||||
|
* security:
|
||||||
|
* - CookieAuth: []
|
||||||
|
* - CsrfToken: []
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Queue data from Sonarr
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* '500':
|
||||||
|
* description: Proxy error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ErrorResponse'
|
||||||
|
*/
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
// Get queue
|
// Get queue
|
||||||
router.get('/queue', async (req, res) => {
|
router.get('/queue', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
|
const response = await axios.get(`${instance.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -20,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
|
// Get history
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
|
const response = await axios.get(`${instance.url}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
|
headers: { 'X-Api-Key': instance.apiKey },
|
||||||
params: { pageSize: req.query.pageSize || 50 }
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
@@ -35,9 +100,13 @@ router.get('/history', async (req, res) => {
|
|||||||
|
|
||||||
// Get series details
|
// Get series details
|
||||||
router.get('/series/:id', async (req, res) => {
|
router.get('/series/:id', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
|
const response = await axios.get(`${instance.url}/api/v3/series/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -47,9 +116,13 @@ router.get('/series/:id', async (req, res) => {
|
|||||||
|
|
||||||
// Get all series with tags
|
// Get all series with tags
|
||||||
router.get('/series', async (req, res) => {
|
router.get('/series', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
|
const response = await axios.get(`${instance.url}/api/v3/series`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -57,24 +130,63 @@ 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)
|
// Notification proxy routes (Phase 3)
|
||||||
// GET /api/sonarr/notifications - list all notifications
|
// GET /api/sonarr/notifications - list all notifications
|
||||||
router.get('/notifications', async (req, res) => {
|
router.get('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Sonarr] Failed to fetch notifications:', error.message);
|
||||||
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/sonarr/notifications/:id - get specific notification
|
// GET /api/sonarr/notifications/:id - get specific notification
|
||||||
router.get('/notifications/:id', async (req, res) => {
|
router.get('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,9 +196,13 @@ router.get('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/sonarr/notifications - create notification
|
// POST /api/sonarr/notifications - create notification
|
||||||
router.post('/notifications', async (req, res) => {
|
router.post('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,9 +212,13 @@ router.post('/notifications', async (req, res) => {
|
|||||||
|
|
||||||
// PUT /api/sonarr/notifications/:id - update notification
|
// PUT /api/sonarr/notifications/:id - update notification
|
||||||
router.put('/notifications/:id', async (req, res) => {
|
router.put('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,9 +228,13 @@ router.put('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// DELETE /api/sonarr/notifications/:id - delete notification
|
// DELETE /api/sonarr/notifications/:id - delete notification
|
||||||
router.delete('/notifications/:id', async (req, res) => {
|
router.delete('/notifications/:id', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,21 +244,34 @@ router.delete('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/sonarr/notifications/test - test notification
|
// POST /api/sonarr/notifications/test - test notification
|
||||||
router.post('/notifications/test', async (req, res) => {
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} 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) });
|
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/sonarr/notifications/schema - get notification schema
|
// GET /api/sonarr/notifications/schema - get notification schema
|
||||||
router.get('/notifications/schema', async (req, res) => {
|
router.get('/notifications/schema', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -144,6 +281,10 @@ router.get('/notifications/schema', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
@@ -158,8 +299,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
@@ -169,36 +310,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
configContract: 'WebhookSettings',
|
configContract: 'WebhookSettings',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'url', value: webhookUrl },
|
{ name: 'url', value: webhookUrl },
|
||||||
{ name: 'method', value: 'POST' },
|
{ name: 'method', value: 1 },
|
||||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
],
|
],
|
||||||
onGrab: true,
|
onGrab: true,
|
||||||
onDownload: true,
|
onDownload: true,
|
||||||
onImport: true,
|
|
||||||
onUpgrade: true,
|
onUpgrade: true,
|
||||||
|
onImport: true,
|
||||||
onRename: false,
|
onRename: false,
|
||||||
onHealthIssue: false,
|
onHealthIssue: false,
|
||||||
onApplicationUpdate: false
|
onApplicationUpdate: false,
|
||||||
|
onManualInteractionRequired: false
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingNotification) {
|
if (existingNotification) {
|
||||||
// Update existing notification
|
// Update existing notification
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
|
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||||
{ ...notificationPayload, id: existingNotification.id },
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} else {
|
} else {
|
||||||
// Create new notification
|
// Create new notification
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${process.env.SONARR_URL}/api/v3/notification`,
|
`${instance.url}/api/v3/notification`,
|
||||||
notificationPayload,
|
notificationPayload,
|
||||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Sonarr] Failed to configure webhook:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Sonarr] Response status:', error.response.status);
|
||||||
|
console.error('[Sonarr] Response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,13 +2,71 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||||
|
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
|
||||||
const router = express.Router();
|
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.
|
// 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.
|
// 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.
|
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
|
||||||
@@ -27,7 +85,9 @@ const VALID_EVENT_TYPES = new Set([
|
|||||||
'DownloadFolderImported', 'ImportFailed',
|
'DownloadFolderImported', 'ImportFailed',
|
||||||
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
||||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
|
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||||
|
// Ombi notification types
|
||||||
|
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||||
@@ -43,9 +103,11 @@ function pruneReplayCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune the replay cache once per minute
|
||||||
|
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||||
|
|
||||||
function isReplay(eventType, instanceName, eventDate) {
|
function isReplay(eventType, instanceName, eventDate) {
|
||||||
if (!eventDate) return false;
|
if (!eventDate) return false;
|
||||||
pruneReplayCache();
|
|
||||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
||||||
if (recentEvents.has(key)) return true;
|
if (recentEvents.has(key)) return true;
|
||||||
recentEvents.set(key, Date.now());
|
recentEvents.set(key, Date.now());
|
||||||
@@ -71,6 +133,16 @@ const HISTORY_EVENTS = new Set([
|
|||||||
'EpisodeFileRenamedBySeries'
|
'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
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
||||||
* @param {Object} req - Express request object
|
* @param {Object} req - Express request object
|
||||||
@@ -105,19 +177,20 @@ function validateWebhookSecret(req) {
|
|||||||
*
|
*
|
||||||
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
||||||
*
|
*
|
||||||
* @param {string} serviceType - 'sonarr' or 'radarr'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the *arr webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
|
|
||||||
if (!affectsQueue && !affectsHistory) {
|
if (!affectsQueue && !affectsHistory && !affectsOmbi) {
|
||||||
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
|
logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`);
|
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`);
|
||||||
|
|
||||||
// Ensure retrievers are initialized (idempotent)
|
// Ensure retrievers are initialized (idempotent)
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
@@ -182,6 +255,16 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
|
||||||
}
|
}
|
||||||
|
} else if (serviceType === 'ombi') {
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
|
if (affectsOmbi) {
|
||||||
|
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
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.
|
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
|
||||||
@@ -217,12 +300,107 @@ function validatePayload(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webhook/sonarr
|
* @openapi
|
||||||
* Receives webhook events from Sonarr instances.
|
* /api/webhook/sonarr:
|
||||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
* 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).
|
||||||
*
|
*
|
||||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* Phase 6: rate limiting, input validation, replay protection.
|
* 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
|
||||||
|
* - 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: []
|
||||||
|
* 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) => {
|
router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||||
if (!validateWebhookSecret(req)) {
|
if (!validateWebhookSecret(req)) {
|
||||||
@@ -237,20 +415,23 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
|
|
||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
if (isReplay(eventType, instanceName, eventDate)) {
|
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}`);
|
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
|
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||||
|
|
||||||
// Phase 5.1: update webhook metrics for polling optimization
|
// Phase 5.1: update webhook metrics for polling optimization
|
||||||
const sonarrInstances = getSonarrInstances();
|
if (inst) {
|
||||||
const instance = sonarrInstances.find(i => i.name === instanceName);
|
cache.updateWebhookMetrics(inst.url);
|
||||||
if (instance) {
|
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`);
|
||||||
cache.updateWebhookMetrics(instance.url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
@@ -266,12 +447,107 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webhook/radarr
|
* @openapi
|
||||||
* Receives webhook events from Radarr instances.
|
* /api/webhook/radarr:
|
||||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
* 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).
|
||||||
*
|
*
|
||||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* Phase 6: rate limiting, input validation, replay protection.
|
* 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
|
||||||
|
* - 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: []
|
||||||
|
* 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) => {
|
router.post('/radarr', webhookLimiter, (req, res) => {
|
||||||
if (!validateWebhookSecret(req)) {
|
if (!validateWebhookSecret(req)) {
|
||||||
@@ -286,20 +562,23 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
|
|
||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
if (isReplay(eventType, instanceName, eventDate)) {
|
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}`);
|
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
|
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||||
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
||||||
|
|
||||||
// Phase 5.1: update webhook metrics for polling optimization
|
// Phase 5.1: update webhook metrics for polling optimization
|
||||||
const radarrInstances = getRadarrInstances();
|
if (inst) {
|
||||||
const instance = radarrInstances.find(i => i.name === instanceName);
|
cache.updateWebhookMetrics(inst.url);
|
||||||
if (instance) {
|
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`);
|
||||||
cache.updateWebhookMetrics(instance.url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
@@ -314,4 +593,179 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 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
|
||||||
|
* - 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`
|
||||||
|
* - Method: POST
|
||||||
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
|
* - Application Token: OMBI_API_KEY
|
||||||
|
* security: []
|
||||||
|
* 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).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;
|
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,596 @@
|
|||||||
|
// 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.arrContentType = 'episode';
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = slot.storage || null;
|
||||||
|
dlObj.targetPath = series.path || null;
|
||||||
|
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;
|
||||||
|
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.arrContentType = 'episode';
|
||||||
|
if (isAdmin) {
|
||||||
|
download.downloadPath = download.savePath || null;
|
||||||
|
download.targetPath = series.path || null;
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
};
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const { logToFile } = require('./logger');
|
const { logToFile } = require('./logger');
|
||||||
|
const cache = require('./cache');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances
|
getRadarrInstances,
|
||||||
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
|
||||||
|
const TagMatcher = require('../services/TagMatcher');
|
||||||
|
|
||||||
// Import retriever classes
|
// Import retriever classes
|
||||||
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
||||||
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
||||||
|
const OmbiRetriever = require('../clients/OmbiRetriever');
|
||||||
|
|
||||||
// Retriever type mapping
|
// Retriever type mapping
|
||||||
const retrieverClasses = {
|
const retrieverClasses = {
|
||||||
sonarr: PollingSonarrRetriever,
|
sonarr: PollingSonarrRetriever,
|
||||||
radarr: PollingRadarrRetriever
|
radarr: PollingRadarrRetriever,
|
||||||
|
ombi: OmbiRetriever
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +41,13 @@ const arrRetrieverRegistry = {
|
|||||||
// Get all instance configurations
|
// Get all instance configurations
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
// Create retriever instances
|
// Create retriever instances
|
||||||
const instanceConfigs = [
|
const instanceConfigs = [
|
||||||
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
||||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
|
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' })),
|
||||||
|
...ombiInstances.map(inst => ({ ...inst, type: 'ombi' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const config of instanceConfigs) {
|
for (const config of instanceConfigs) {
|
||||||
@@ -51,8 +59,9 @@ const arrRetrieverRegistry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retriever = new RetrieverClass(config);
|
const retriever = new RetrieverClass(config);
|
||||||
this.retrievers.set(config.id, retriever);
|
const uniqueKey = `${config.type}:${config.id}`;
|
||||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
|
this.retrievers.set(uniqueKey, retriever);
|
||||||
|
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -301,7 +310,151 @@ const arrRetrieverRegistry = {
|
|||||||
.filter(result => result.status === 'fulfilled')
|
.filter(result => result.status === 'fulfilled')
|
||||||
.map(result => result.value)
|
.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;
|
module.exports = arrRetrieverRegistry;
|
||||||
|
|||||||
@@ -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() {
|
function getQbittorrentInstances() {
|
||||||
return parseInstances(
|
return parseInstances(
|
||||||
process.env.QBITTORRENT_INSTANCES,
|
process.env.QBITTORRENT_INSTANCES,
|
||||||
@@ -126,6 +134,7 @@ module.exports = {
|
|||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
|
getOmbiInstances,
|
||||||
getQbittorrentInstances,
|
getQbittorrentInstances,
|
||||||
getTransmissionInstances,
|
getTransmissionInstances,
|
||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ class DownloadClientRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new ClientClass(config);
|
const client = new ClientClass(config);
|
||||||
this.clients.set(config.id, client);
|
const uniqueKey = `${config.type}:${config.id}`;
|
||||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
|
this.clients.set(uniqueKey, client);
|
||||||
|
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -148,18 +149,42 @@ class DownloadClientRegistry {
|
|||||||
const clients = this.getAllClients();
|
const clients = this.getAllClients();
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
// Group by client type
|
if (clients.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset fallback flags for qBittorrent clients
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (client.resetFallbackFlag) {
|
||||||
|
client.resetFallbackFlag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch downloads from all clients in parallel
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
clients.map(async (client) => {
|
||||||
|
const downloads = await client.getActiveDownloads();
|
||||||
|
return {
|
||||||
|
type: client.getClientType(),
|
||||||
|
downloads
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by client type
|
||||||
|
for (let i = 0; i < clients.length; i++) {
|
||||||
|
const client = clients[i];
|
||||||
const type = client.getClientType();
|
const type = client.getClientType();
|
||||||
if (!result[type]) {
|
if (!result[type]) {
|
||||||
result[type] = [];
|
result[type] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const res = results[i];
|
||||||
const downloads = await client.getActiveDownloads();
|
if (res.status === 'fulfilled' && res.value) {
|
||||||
result[type].push(...downloads);
|
result[type].push(...res.value.downloads);
|
||||||
} catch (error) {
|
} else {
|
||||||
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
|
const errorMsg = res.status === 'rejected' ? res.reason?.message : 'Unknown error';
|
||||||
|
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ const arrRetrieverRegistry = require('./arrRetrievers');
|
|||||||
// History changes slowly compared to active downloads.
|
// History changes slowly compared to active downloads.
|
||||||
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Staged loading configuration
|
||||||
|
const INITIAL_PAGE_SIZE = 100;
|
||||||
|
const MAX_TOTAL_RECORDS = 1000;
|
||||||
|
const MAX_PAGES = 10; // 10 pages * 100 records = 1000 total
|
||||||
|
|
||||||
|
// Background fetch state to prevent concurrent fetches
|
||||||
|
const backgroundFetchState = {
|
||||||
|
sonarr: { inProgress: false, lastFetchTime: 0 },
|
||||||
|
radarr: { inProgress: false, lastFetchTime: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event subscribers for history updates
|
||||||
|
const historyUpdateSubscribers = new Set();
|
||||||
|
|
||||||
// Sonarr event types that represent a successful import
|
// Sonarr event types that represent a successful import
|
||||||
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||||
// Sonarr event types that represent a failed import
|
// Sonarr event types that represent a failed import
|
||||||
@@ -18,13 +32,20 @@ const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
|||||||
/**
|
/**
|
||||||
* Fetch recent history records from all Sonarr instances for the given date window.
|
* Fetch recent history records from all Sonarr instances for the given date window.
|
||||||
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
||||||
|
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
|
||||||
* @param {Date} since - Only include records on or after this date
|
* @param {Date} since - Only include records on or after this date
|
||||||
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
||||||
*/
|
*/
|
||||||
async function fetchSonarrHistory(since) {
|
async function fetchSonarrHistory(since) {
|
||||||
const cacheKey = 'history:sonarr';
|
const cacheKey = 'history:sonarr';
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
// Only trigger background refresh if cache is incomplete (less than max records)
|
||||||
|
if (!backgroundFetchState.sonarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
|
||||||
|
triggerBackgroundSonarrFetch(since);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure retrievers are initialized
|
// Ensure retrievers are initialized
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
@@ -32,13 +53,15 @@ async function fetchSonarrHistory(since) {
|
|||||||
const instances = getSonarrInstances();
|
const instances = getSonarrInstances();
|
||||||
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
||||||
|
|
||||||
|
// Stage 1: Fetch initial batch (100 records)
|
||||||
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
||||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||||
if (!inst) return [];
|
if (!inst) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await retriever.getHistory({
|
const response = await retriever.getHistory({
|
||||||
pageSize: 100,
|
pageSize: INITIAL_PAGE_SIZE,
|
||||||
|
maxPages: 1,
|
||||||
sortKey: 'date',
|
sortKey: 'date',
|
||||||
sortDir: 'descending',
|
sortDir: 'descending',
|
||||||
includeSeries: true,
|
includeSeries: true,
|
||||||
@@ -61,19 +84,96 @@ async function fetchSonarrHistory(since) {
|
|||||||
|
|
||||||
const flat = results.flat();
|
const flat = results.flat();
|
||||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||||
|
|
||||||
|
// Stage 2: Trigger background fetch for remaining records
|
||||||
|
triggerBackgroundSonarrFetch(since);
|
||||||
|
|
||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger background fetch for remaining Sonarr history records.
|
||||||
|
* Uses the retriever's built-in pagination to fetch up to 1000 records.
|
||||||
|
*/
|
||||||
|
async function triggerBackgroundSonarrFetch(since) {
|
||||||
|
if (backgroundFetchState.sonarr.inProgress) return;
|
||||||
|
|
||||||
|
// Debounce: don't fetch if we fetched within the last minute
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - backgroundFetchState.sonarr.lastFetchTime < 60000) return;
|
||||||
|
|
||||||
|
backgroundFetchState.sonarr.inProgress = true;
|
||||||
|
backgroundFetchState.sonarr.lastFetchTime = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
const instances = getSonarrInstances();
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
||||||
|
|
||||||
|
// Fetch all records up to MAX_PAGES using built-in pagination
|
||||||
|
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
||||||
|
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||||
|
if (!inst) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await retriever.getHistory({
|
||||||
|
pageSize: INITIAL_PAGE_SIZE,
|
||||||
|
maxPages: MAX_PAGES,
|
||||||
|
sortKey: 'date',
|
||||||
|
sortDir: 'descending',
|
||||||
|
includeSeries: true,
|
||||||
|
includeEpisode: true,
|
||||||
|
startDate: since.toISOString()
|
||||||
|
});
|
||||||
|
const records = (response && response.records) || [];
|
||||||
|
return records.map(r => {
|
||||||
|
if (r.series) r.series._instanceUrl = inst.url;
|
||||||
|
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||||
|
r._instanceUrl = inst.url;
|
||||||
|
r._instanceName = inst.name || inst.id;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HistoryFetcher] Sonarr background fetch ${inst.id} error:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allRecords = results.flat();
|
||||||
|
|
||||||
|
// Only update cache if we got records (don't overwrite with empty data on failure)
|
||||||
|
if (allRecords.length > 0) {
|
||||||
|
cache.set('history:sonarr', allRecords, HISTORY_CACHE_TTL);
|
||||||
|
|
||||||
|
// Emit SSE event for history update
|
||||||
|
emitHistoryUpdate('sonarr');
|
||||||
|
|
||||||
|
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Sonarr records`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HistoryFetcher] Background Sonarr fetch error:', err.message);
|
||||||
|
} finally {
|
||||||
|
backgroundFetchState.sonarr.inProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recent history records from all Radarr instances for the given date window.
|
* Fetch recent history records from all Radarr instances for the given date window.
|
||||||
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||||
|
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
|
||||||
* @param {Date} since - Only include records on or after this date
|
* @param {Date} since - Only include records on or after this date
|
||||||
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
||||||
*/
|
*/
|
||||||
async function fetchRadarrHistory(since) {
|
async function fetchRadarrHistory(since) {
|
||||||
const cacheKey = 'history:radarr';
|
const cacheKey = 'history:radarr';
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
// Only trigger background refresh if cache is incomplete (less than max records)
|
||||||
|
if (!backgroundFetchState.radarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
|
||||||
|
triggerBackgroundRadarrFetch(since);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure retrievers are initialized
|
// Ensure retrievers are initialized
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
@@ -81,13 +181,15 @@ async function fetchRadarrHistory(since) {
|
|||||||
const instances = getRadarrInstances();
|
const instances = getRadarrInstances();
|
||||||
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
||||||
|
|
||||||
|
// Stage 1: Fetch initial batch (100 records)
|
||||||
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
||||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||||
if (!inst) return [];
|
if (!inst) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await retriever.getHistory({
|
const response = await retriever.getHistory({
|
||||||
pageSize: 100,
|
pageSize: INITIAL_PAGE_SIZE,
|
||||||
|
maxPages: 1,
|
||||||
sortKey: 'date',
|
sortKey: 'date',
|
||||||
sortDir: 'descending',
|
sortDir: 'descending',
|
||||||
includeMovie: true,
|
includeMovie: true,
|
||||||
@@ -109,9 +211,109 @@ async function fetchRadarrHistory(since) {
|
|||||||
|
|
||||||
const flat = results.flat();
|
const flat = results.flat();
|
||||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||||
|
|
||||||
|
// Stage 2: Trigger background fetch for remaining records
|
||||||
|
triggerBackgroundRadarrFetch(since);
|
||||||
|
|
||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger background fetch for remaining Radarr history records.
|
||||||
|
* Uses the retriever's built-in pagination to fetch up to 1000 records.
|
||||||
|
*/
|
||||||
|
async function triggerBackgroundRadarrFetch(since) {
|
||||||
|
if (backgroundFetchState.radarr.inProgress) return;
|
||||||
|
|
||||||
|
// Debounce: don't fetch if we fetched within the last minute
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - backgroundFetchState.radarr.lastFetchTime < 60000) return;
|
||||||
|
|
||||||
|
backgroundFetchState.radarr.inProgress = true;
|
||||||
|
backgroundFetchState.radarr.lastFetchTime = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
const instances = getRadarrInstances();
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
||||||
|
|
||||||
|
// Fetch all records up to MAX_PAGES using built-in pagination
|
||||||
|
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
||||||
|
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||||
|
if (!inst) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await retriever.getHistory({
|
||||||
|
pageSize: INITIAL_PAGE_SIZE,
|
||||||
|
maxPages: MAX_PAGES,
|
||||||
|
sortKey: 'date',
|
||||||
|
sortDir: 'descending',
|
||||||
|
includeMovie: true,
|
||||||
|
startDate: since.toISOString()
|
||||||
|
});
|
||||||
|
const records = (response && response.records) || [];
|
||||||
|
return records.map(r => {
|
||||||
|
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||||
|
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||||
|
r._instanceUrl = inst.url;
|
||||||
|
r._instanceName = inst.name || inst.id;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HistoryFetcher] Radarr background fetch ${inst.id} error:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allRecords = results.flat();
|
||||||
|
|
||||||
|
// Only update cache if we got records (don't overwrite with empty data on failure)
|
||||||
|
if (allRecords.length > 0) {
|
||||||
|
cache.set('history:radarr', allRecords, HISTORY_CACHE_TTL);
|
||||||
|
|
||||||
|
// Emit SSE event for history update
|
||||||
|
emitHistoryUpdate('radarr');
|
||||||
|
|
||||||
|
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Radarr records`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HistoryFetcher] Background Radarr fetch error:', err.message);
|
||||||
|
} finally {
|
||||||
|
backgroundFetchState.radarr.inProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to history update events.
|
||||||
|
* @param {Function} callback - Function to call when history is updated
|
||||||
|
*/
|
||||||
|
function onHistoryUpdate(callback) {
|
||||||
|
historyUpdateSubscribers.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from history update events.
|
||||||
|
* @param {Function} callback - Function to remove from subscribers
|
||||||
|
*/
|
||||||
|
function offHistoryUpdate(callback) {
|
||||||
|
historyUpdateSubscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit SSE event for history update.
|
||||||
|
* Notifies all subscribers when history cache is updated.
|
||||||
|
*/
|
||||||
|
function emitHistoryUpdate(type) {
|
||||||
|
console.log(`[HistoryFetcher] History updated for ${type}, notifying ${historyUpdateSubscribers.size} subscribers`);
|
||||||
|
historyUpdateSubscribers.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(type);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HistoryFetcher] Error in history update subscriber:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a Sonarr history record's event type.
|
* Classify a Sonarr history record's event type.
|
||||||
* @param {string} eventType
|
* @param {string} eventType
|
||||||
@@ -149,5 +351,7 @@ module.exports = {
|
|||||||
classifySonarrEvent,
|
classifySonarrEvent,
|
||||||
classifyRadarrEvent,
|
classifyRadarrEvent,
|
||||||
invalidateHistoryCache,
|
invalidateHistoryCache,
|
||||||
|
onHistoryUpdate,
|
||||||
|
offHistoryUpdate,
|
||||||
HISTORY_CACHE_TTL
|
HISTORY_CACHE_TTL
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
|
|
||||||
// Falls back to ../../data/server.log (same directory index.js uses).
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
|
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
||||||
|
|
||||||
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
|
|
||||||
|
|
||||||
function logToFile(message) {
|
function logToFile(message) {
|
||||||
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
|
console.log(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Pure filter / sort / search utilities for Ombi requests.
|
||||||
|
* Must stay in sync with client/src/utils/ombiFilters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a single status string from an Ombi request object.
|
||||||
|
* Priority: available > denied > approved > pending > unknown
|
||||||
|
*
|
||||||
|
* @param {Object} request
|
||||||
|
* @returns {string} 'available' | 'denied' | 'approved' | 'pending' | 'unknown'
|
||||||
|
*/
|
||||||
|
function getRequestStatus(request) {
|
||||||
|
if (!request) return 'unknown';
|
||||||
|
if (request.available) return 'available';
|
||||||
|
if (request.denied) return 'denied';
|
||||||
|
if (request.approved) return 'approved';
|
||||||
|
if (request.requested) return 'pending';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter requests by media type.
|
||||||
|
*
|
||||||
|
* @param {Array} requests
|
||||||
|
* @param {string[]} types - e.g. ['movie', 'tv'] or ['all']
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
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 - e.g. ['pending', 'approved', 'available', 'denied']
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
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}
|
||||||
|
*/
|
||||||
|
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 - requestedDate_desc | requestedDate_asc | title_asc | title_desc
|
||||||
|
* @returns {Array} new sorted array
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @param {string[]} options.types
|
||||||
|
* @param {string[]} options.statuses
|
||||||
|
* @param {string} options.sort
|
||||||
|
* @param {string} options.search
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRequestStatus,
|
||||||
|
filterByType,
|
||||||
|
filterByStatus,
|
||||||
|
filterBySearch,
|
||||||
|
sortRequests,
|
||||||
|
applyRequestFilters
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Helper functions for extracting user information from Ombi API responses.
|
||||||
|
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
|
||||||
|
* not a string, so we need to extract the username from the object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the username from an Ombi request object.
|
||||||
|
* Handles both the OmbiUser object format and legacy string format.
|
||||||
|
*
|
||||||
|
* @param {Object} request - The Ombi request object
|
||||||
|
* @returns {string} The extracted username, or empty string if not found
|
||||||
|
*/
|
||||||
|
function extractRequestedUser(request) {
|
||||||
|
if (!request) return '';
|
||||||
|
|
||||||
|
const requestedUser = request.requestedUser || request.RequestedUser;
|
||||||
|
|
||||||
|
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||||
|
if (requestedUser && typeof requestedUser === 'object') {
|
||||||
|
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||||
|
return requestedUser.alias || requestedUser.Alias ||
|
||||||
|
requestedUser.userAlias || requestedUser.UserAlias ||
|
||||||
|
requestedUser.userName || requestedUser.UserName ||
|
||||||
|
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
||||||
|
request.requestedByAlias || request.RequestedByAlias || '';
|
||||||
|
}
|
||||||
|
// Handle string format (fallback for compatibility)
|
||||||
|
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
|
if (!Array.isArray(requests)) return [];
|
||||||
|
if (showAll || !username) return requests;
|
||||||
|
const usernameLower = username.toLowerCase();
|
||||||
|
return requests.filter(req => {
|
||||||
|
const requestedUser = extractRequestedUser(req);
|
||||||
|
return requestedUser.toLowerCase() === usernameLower;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extractRequestedUser,
|
||||||
|
filterRequestsByUser
|
||||||
|
};
|
||||||
@@ -5,7 +5,8 @@ const { initializeClients, getAllDownloads, getDownloadsByClientType } = require
|
|||||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances
|
getRadarrInstances,
|
||||||
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
|
||||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||||
@@ -88,13 +89,14 @@ async function pollAllServices() {
|
|||||||
|
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
||||||
const globalMetrics = cache.getGlobalWebhookMetrics();
|
const globalMetrics = cache.getGlobalWebhookMetrics();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
||||||
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
||||||
|
|
||||||
if (fallbackTriggered) {
|
if (fallbackTriggered) {
|
||||||
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
|
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ async function pollAllServices() {
|
|||||||
// Determine which instances should be polled based on webhook activity
|
// Determine which instances should be polled based on webhook activity
|
||||||
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
||||||
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
||||||
|
const shouldPollOmbi = fallbackTriggered || !shouldSkipInstancePolling(ombiInstances, 'ombi');
|
||||||
|
|
||||||
// All fetches in parallel, each individually timed
|
// All fetches in parallel, each individually timed
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
@@ -118,7 +121,7 @@ async function pollAllServices() {
|
|||||||
return queuesByType.sonarr || [];
|
return queuesByType.sonarr || [];
|
||||||
}) : timed('Sonarr Queue', async () => []),
|
}) : timed('Sonarr Queue', async () => []),
|
||||||
shouldPollSonarr ? timed('Sonarr History', async () => {
|
shouldPollSonarr ? timed('Sonarr History', async () => {
|
||||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
|
||||||
return historyByType.sonarr || [];
|
return historyByType.sonarr || [];
|
||||||
}) : timed('Sonarr History', async () => []),
|
}) : timed('Sonarr History', async () => []),
|
||||||
shouldPollRadarr ? timed('Radarr Queue', async () => {
|
shouldPollRadarr ? timed('Radarr Queue', async () => {
|
||||||
@@ -126,13 +129,17 @@ async function pollAllServices() {
|
|||||||
return queuesByType.radarr || [];
|
return queuesByType.radarr || [];
|
||||||
}) : timed('Radarr Queue', async () => []),
|
}) : timed('Radarr Queue', async () => []),
|
||||||
shouldPollRadarr ? timed('Radarr History', async () => {
|
shouldPollRadarr ? timed('Radarr History', async () => {
|
||||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
|
||||||
return historyByType.radarr || [];
|
return historyByType.radarr || [];
|
||||||
}) : timed('Radarr History', async () => []),
|
}) : timed('Radarr History', async () => []),
|
||||||
shouldPollRadarr ? timed('Radarr Tags', async () => {
|
shouldPollRadarr ? timed('Radarr Tags', async () => {
|
||||||
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
||||||
return tagsByType.radarr || [];
|
return tagsByType.radarr || [];
|
||||||
}) : timed('Radarr Tags', async () => []),
|
}) : timed('Radarr Tags', async () => []),
|
||||||
|
shouldPollOmbi ? timed('Ombi Requests', async () => {
|
||||||
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||||
|
return ombiRequests;
|
||||||
|
}) : timed('Ombi Requests', async () => ({ movie: [], tv: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -140,7 +147,8 @@ async function pollAllServices() {
|
|||||||
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||||
{ result: sonarrHistories },
|
{ result: sonarrHistories },
|
||||||
{ result: radarrQueues }, { result: radarrHistories },
|
{ result: radarrQueues }, { result: radarrHistories },
|
||||||
{ result: radarrTagsResults }
|
{ result: radarrTagsResults },
|
||||||
|
{ result: ombiRequests }
|
||||||
] = results;
|
] = results;
|
||||||
|
|
||||||
// Store per-task timings
|
// Store per-task timings
|
||||||
@@ -178,10 +186,12 @@ async function pollAllServices() {
|
|||||||
cat: d.category,
|
cat: d.category,
|
||||||
labels: d.tags.join(','),
|
labels: d.tags.join(','),
|
||||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||||
raw: d.raw
|
raw: d.raw,
|
||||||
|
instanceId: d.instanceId,
|
||||||
|
instanceName: d.instanceName
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabHistoryLegacy = {
|
const sabHistoryLegacy = {
|
||||||
slots: sabHistory.map(d => ({
|
slots: sabHistory.map(d => ({
|
||||||
nzo_id: d.id,
|
nzo_id: d.id,
|
||||||
@@ -191,7 +201,9 @@ async function pollAllServices() {
|
|||||||
cat: d.category,
|
cat: d.category,
|
||||||
labels: d.tags.join(','),
|
labels: d.tags.join(','),
|
||||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||||
raw: d.raw
|
raw: d.raw,
|
||||||
|
instanceId: d.instanceId,
|
||||||
|
instanceName: d.instanceName
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,6 +290,16 @@ async function pollAllServices() {
|
|||||||
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
|
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ombi
|
||||||
|
if (shouldPollOmbi) {
|
||||||
|
cache.set('poll:ombi-requests', ombiRequests, cacheTTL);
|
||||||
|
logToFile(`[Poller] Ombi requests cached: ${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows`);
|
||||||
|
} else {
|
||||||
|
// Extend TTL of existing cached data when polling is skipped
|
||||||
|
const existingOmbiRequests = cache.get('poll:ombi-requests');
|
||||||
|
if (existingOmbiRequests) cache.set('poll:ombi-requests', existingOmbiRequests, cacheTTL);
|
||||||
|
}
|
||||||
|
|
||||||
// qBittorrent (already set above in download clients section)
|
// qBittorrent (already set above in download clients section)
|
||||||
|
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ function mapTorrentToDownload(torrent) {
|
|||||||
return {
|
return {
|
||||||
type: 'torrent',
|
type: 'torrent',
|
||||||
title: torrent.name,
|
title: torrent.name,
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceId: torrent.instanceId,
|
||||||
instanceName: torrent.instanceName,
|
instanceName: torrent.instanceName,
|
||||||
status: status,
|
status: status,
|
||||||
progress: progress.toFixed(1),
|
progress: progress.toFixed(1),
|
||||||
|
|||||||
@@ -38,13 +38,24 @@ tests/
|
|||||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
│ ├── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||||
|
│ └── dashboard.test.js # Pure helper functions: getCoverArt, extractAllTags,
|
||||||
|
│ # extractUserTag, sanitizeTagLabel, tagMatchesUser,
|
||||||
|
│ # getImportIssues, getSonarrLink, getRadarrLink,
|
||||||
|
│ # canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges
|
||||||
└── integration/
|
└── integration/
|
||||||
├── health.test.js # GET /health and /ready endpoints
|
├── health.test.js # GET /health and /ready endpoints
|
||||||
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||||
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
||||||
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
├── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
||||||
# replay protection, metrics, security assertions
|
│ # replay protection, metrics, security assertions
|
||||||
|
├── dashboard.test.js # GET /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll,
|
||||||
|
│ # paused queue, history, importIssues), GET /status,
|
||||||
|
│ # GET /webhook-metrics, GET /cover-art, POST /blocklist-search
|
||||||
|
├── emby.test.js # GET /sessions, /users, /users/:id, /session/:id/user
|
||||||
|
└── arrRoutes.test.js # Sonarr + Radarr: queue, history, series/movies, notifications
|
||||||
|
# CRUD, /test, /schema, /sofarr-webhook (create + update)
|
||||||
|
# SABnzbd: queue, history
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
@@ -57,15 +68,30 @@ tests/
|
|||||||
|
|
||||||
## Coverage targets
|
## Coverage targets
|
||||||
|
|
||||||
The tested files meet these per-file minimums (enforced in CI):
|
Global thresholds (enforced in CI via `vitest.config.js`):
|
||||||
|
|
||||||
| File | Lines | Branches |
|
| Metric | Threshold |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `server/app.js` | 85% | 65% |
|
| Statements | 55% |
|
||||||
| `server/routes/auth.js` | 85% | 70% |
|
| Functions | 55% |
|
||||||
| `server/routes/webhook.js` | 80% | 70% |
|
| Branches | 40% |
|
||||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
| Lines | 55% |
|
||||||
| `server/utils/sanitizeError.js` | 60% | — |
|
|
||||||
| `server/utils/config.js` | 50% | 55% |
|
|
||||||
|
|
||||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
Notable per-file coverage after the current suite:
|
||||||
|
|
||||||
|
| File | Lines | Branches | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `server/app.js` | ~92% | ~71% | |
|
||||||
|
| `server/routes/auth.js` | ~88% | ~78% | |
|
||||||
|
| `server/routes/dashboard.js` | ~42% | ~25% | SSE `/stream` endpoint intentionally untested |
|
||||||
|
| `server/routes/emby.js` | 100% | 100% | |
|
||||||
|
| `server/routes/radarr.js` | ~87% | ~77% | |
|
||||||
|
| `server/routes/sonarr.js` | ~89% | ~82% | |
|
||||||
|
| `server/routes/sabnzbd.js` | 100% | 100% | |
|
||||||
|
| `server/routes/webhook.js` | ~85% | ~79% | |
|
||||||
|
| `server/middleware/requireAuth.js` | ~92% | ~81% | |
|
||||||
|
| `server/middleware/verifyCsrf.js` | 100% | 80% | |
|
||||||
|
| `server/utils/sanitizeError.js` | 100% | 75% | |
|
||||||
|
| `server/utils/config.js` | ~70% | ~58% | |
|
||||||
|
|
||||||
|
`poller.js` (background polling engine) and the SSE `/stream` endpoint in `dashboard.js` require time-based behaviour and long-lived HTTP connections; they remain tracked as future test coverage work.
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/state.js
|
||||||
|
*
|
||||||
|
* Verifies the structure and initial values of the state object.
|
||||||
|
* This ensures the Ombi-related state fields are properly defined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state } from '../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('state object', () => {
|
||||||
|
it('has ombiBaseUrl field initialized to null', () => {
|
||||||
|
expect(state).toHaveProperty('ombiBaseUrl');
|
||||||
|
expect(state.ombiBaseUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has ombiRequests field initialized to null', () => {
|
||||||
|
expect(state).toHaveProperty('ombiRequests');
|
||||||
|
expect(state.ombiRequests).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has ombiWebhook field with correct structure', () => {
|
||||||
|
expect(state).toHaveProperty('ombiWebhook');
|
||||||
|
expect(state.ombiWebhook).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: false,
|
||||||
|
requestApproved: false,
|
||||||
|
requestDeclined: false,
|
||||||
|
requestPending: false,
|
||||||
|
requestProcessing: false
|
||||||
|
},
|
||||||
|
stats: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has ombiWebhook triggers with all required fields', () => {
|
||||||
|
const { triggers } = state.ombiWebhook;
|
||||||
|
expect(triggers).toHaveProperty('requestAvailable');
|
||||||
|
expect(triggers).toHaveProperty('requestApproved');
|
||||||
|
expect(triggers).toHaveProperty('requestDeclined');
|
||||||
|
expect(triggers).toHaveProperty('requestPending');
|
||||||
|
expect(triggers).toHaveProperty('requestProcessing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has all Ombi trigger fields initialized to false', () => {
|
||||||
|
const { triggers } = state.ombiWebhook;
|
||||||
|
expect(triggers.requestAvailable).toBe(false);
|
||||||
|
expect(triggers.requestApproved).toBe(false);
|
||||||
|
expect(triggers.requestDeclined).toBe(false);
|
||||||
|
expect(triggers.requestPending).toBe(false);
|
||||||
|
expect(triggers.requestProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has ombiWebhook stats initialized to null', () => {
|
||||||
|
expect(state.ombiWebhook.stats).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has ombiWebhook enabled initialized to false', () => {
|
||||||
|
expect(state.ombiWebhook.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enabled tests with robust mocking
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { enableOmbiWebhook as apiEnableOmbiWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../../client/src/api.js';
|
||||||
|
import { renderWebhookStatus, enableOmbiWebhook as uiEnableOmbiWebhook, testOmbiWebhook as uiTestOmbiWebhook } from '../../client/src/ui/webhooks.js';
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockImplementation((url, init) => {
|
||||||
|
if (url === '/api/ombi/webhook/enable') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/ombi/webhook/test') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/webhook/config') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ valid: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/sonarr/notifications') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/radarr/notifications') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/ombi/webhook/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
enabled: true,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: true,
|
||||||
|
requestApproved: true,
|
||||||
|
requestDeclined: true,
|
||||||
|
requestPending: true,
|
||||||
|
requestProcessing: true
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
eventsReceived: 10,
|
||||||
|
pollsSkipped: 5,
|
||||||
|
lastWebhookTimestamp: Date.now() - 60000
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/webhook/metrics') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDomForOmbiWebhooks() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="webhooks-section"></div>
|
||||||
|
<div id="webhooks-content"></div>
|
||||||
|
<div id="webhooks-toggle"></div>
|
||||||
|
<div id="webhook-loading" class="hidden"></div>
|
||||||
|
<div id="sonarr-status"></div>
|
||||||
|
<button id="enable-sonarr-webhook"></button>
|
||||||
|
<button id="test-sonarr-webhook"></button>
|
||||||
|
<div id="sonarr-triggers"></div>
|
||||||
|
<div id="sonarr-stats"></div>
|
||||||
|
<div id="radarr-status"></div>
|
||||||
|
<button id="enable-radarr-webhook"></button>
|
||||||
|
<button id="test-radarr-webhook"></button>
|
||||||
|
<div id="radarr-triggers"></div>
|
||||||
|
<div id="radarr-stats"></div>
|
||||||
|
<div id="ombi-status"></div>
|
||||||
|
<button id="enable-ombi-webhook"></button>
|
||||||
|
<button id="test-ombi-webhook"></button>
|
||||||
|
<div id="ombi-triggers" class="hidden">
|
||||||
|
<div id="ombi-requestAvailable"></div>
|
||||||
|
<div id="ombi-requestApproved"></div>
|
||||||
|
<div id="ombi-requestDeclined"></div>
|
||||||
|
<div id="ombi-requestPending"></div>
|
||||||
|
<div id="ombi-requestProcessing"></div>
|
||||||
|
</div>
|
||||||
|
<div id="ombi-stats" class="hidden">
|
||||||
|
<div id="ombi-events"></div>
|
||||||
|
<div id="ombi-polls"></div>
|
||||||
|
<div id="ombi-last"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('frontend API functions (enableOmbiWebhook, testOmbiWebhook)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
mockFetch.mockClear();
|
||||||
|
state.csrfToken = 'test-csrf-token';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enableOmbiWebhook makes POST request to /api/ombi/webhook/enable', async () => {
|
||||||
|
const result = await apiEnableOmbiWebhook();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/enable', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': 'test-csrf-token' }
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testOmbiWebhook makes POST request to /api/ombi/webhook/test', async () => {
|
||||||
|
const result = await apiTestOmbiWebhook();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': 'test-csrf-token' }
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frontend UI functions (webhooks.js Ombi functions)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
mockFetch.mockClear();
|
||||||
|
global.alert = vi.fn();
|
||||||
|
setupDomForOmbiWebhooks();
|
||||||
|
state.csrfToken = 'test-csrf-token';
|
||||||
|
|
||||||
|
// Set up default state for Ombi webhook
|
||||||
|
state.ombiWebhook = {
|
||||||
|
enabled: false,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: false,
|
||||||
|
requestApproved: false,
|
||||||
|
requestDeclined: false,
|
||||||
|
requestPending: false,
|
||||||
|
requestProcessing: false
|
||||||
|
},
|
||||||
|
stats: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
delete global.alert;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renderWebhookStatus renders Ombi webhook status correctly', () => {
|
||||||
|
// 1. Test disabled state
|
||||||
|
state.ombiWebhook.enabled = false;
|
||||||
|
renderWebhookStatus();
|
||||||
|
expect(document.getElementById('ombi-status').textContent).toBe('○ Disabled');
|
||||||
|
expect(document.getElementById('enable-ombi-webhook').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.getElementById('test-ombi-webhook').classList.contains('hidden')).toBe(true);
|
||||||
|
expect(document.getElementById('ombi-triggers').classList.contains('hidden')).toBe(true);
|
||||||
|
|
||||||
|
// 2. Test enabled state with triggers and stats
|
||||||
|
state.ombiWebhook.enabled = true;
|
||||||
|
state.ombiWebhook.triggers.requestAvailable = true;
|
||||||
|
state.ombiWebhook.triggers.requestApproved = true;
|
||||||
|
state.ombiWebhook.stats = {
|
||||||
|
eventsReceived: 42,
|
||||||
|
pollsSkipped: 17,
|
||||||
|
lastWebhookTimestamp: Date.now() - 3600000 // 1 hour ago
|
||||||
|
};
|
||||||
|
renderWebhookStatus();
|
||||||
|
expect(document.getElementById('ombi-status').textContent).toBe('● Enabled');
|
||||||
|
expect(document.getElementById('enable-ombi-webhook').classList.contains('hidden')).toBe(true);
|
||||||
|
expect(document.getElementById('test-ombi-webhook').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.getElementById('ombi-triggers').classList.contains('hidden')).toBe(false);
|
||||||
|
|
||||||
|
// Check triggers rendering
|
||||||
|
expect(document.getElementById('ombi-requestAvailable').textContent).toBe('✓');
|
||||||
|
expect(document.getElementById('ombi-requestApproved').textContent).toBe('✓');
|
||||||
|
expect(document.getElementById('ombi-requestDeclined').textContent).toBe('✗');
|
||||||
|
|
||||||
|
// Check stats rendering
|
||||||
|
expect(document.getElementById('ombi-stats').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.getElementById('ombi-events').textContent).toBe('42');
|
||||||
|
expect(document.getElementById('ombi-polls').textContent).toBe('17');
|
||||||
|
expect(document.getElementById('ombi-last').textContent).toBe('1h ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enableOmbiWebhook UI handler calls API and updates state', async () => {
|
||||||
|
// Mock the state returned by fetchWebhookStatus to enable it
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
if (url === '/api/ombi/webhook/enable') {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||||
|
}
|
||||||
|
if (url === '/api/ombi/webhook/status') {
|
||||||
|
// Return updated state where it is enabled
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
enabled: true,
|
||||||
|
triggers: {
|
||||||
|
requestAvailable: true,
|
||||||
|
requestApproved: false,
|
||||||
|
requestDeclined: false,
|
||||||
|
requestPending: false,
|
||||||
|
requestProcessing: false
|
||||||
|
},
|
||||||
|
stats: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// For all other config fetches, return basic values
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await uiEnableOmbiWebhook();
|
||||||
|
|
||||||
|
// Should make POST call to enable
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/enable', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': 'test-csrf-token' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// State should be updated
|
||||||
|
expect(state.ombiWebhook.enabled).toBe(true);
|
||||||
|
|
||||||
|
// Render the webhook status to update the DOM
|
||||||
|
renderWebhookStatus();
|
||||||
|
|
||||||
|
// UI should show enabled status
|
||||||
|
expect(document.getElementById('ombi-status').textContent).toBe('● Enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testOmbiWebhook UI handler calls API and updates state', async () => {
|
||||||
|
await uiTestOmbiWebhook();
|
||||||
|
|
||||||
|
// Should make POST call to test
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': 'test-csrf-token' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should alert success
|
||||||
|
expect(global.alert).toHaveBeenCalledWith('Ombi webhook test sent successfully!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/downloads.js
|
||||||
|
*
|
||||||
|
* Verifies DOM rendering functions for tag badges and client logos.
|
||||||
|
* Uses jsdom to create and assert DOM structure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderTagBadges } from '../../../client/src/ui/downloads.js';
|
||||||
|
|
||||||
|
describe('renderTagBadges', () => {
|
||||||
|
it('returns empty fragment when showAll is false and no matchedUserTag', () => {
|
||||||
|
const result = renderTagBadges([], false, null);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty fragment when tagBadges is empty', () => {
|
||||||
|
const result = renderTagBadges([], true, null);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders single matched badge when matchedUserTag is provided', () => {
|
||||||
|
const result = renderTagBadges([], false, 'user1');
|
||||||
|
expect(result.childNodes.length).toBe(1);
|
||||||
|
const badge = result.childNodes[0];
|
||||||
|
expect(badge.className).toBe('download-user-badge');
|
||||||
|
expect(badge.textContent).toBe('user1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unmatched badges when showAll is true', () => {
|
||||||
|
const tagBadges = [{ label: 'tag1', matchedUser: null }];
|
||||||
|
const result = renderTagBadges(tagBadges, true, null);
|
||||||
|
expect(result.childNodes.length).toBe(1);
|
||||||
|
const badge = result.childNodes[0];
|
||||||
|
expect(badge.className).toBe('download-user-badge unmatched');
|
||||||
|
expect(badge.textContent).toBe('tag1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders matched badges when showAll is true', () => {
|
||||||
|
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
|
||||||
|
const result = renderTagBadges(tagBadges, true, null);
|
||||||
|
expect(result.childNodes.length).toBe(1);
|
||||||
|
const badge = result.childNodes[0];
|
||||||
|
expect(badge.className).toBe('download-user-badge');
|
||||||
|
expect(badge.textContent).toBe('user1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple badges in correct order (unmatched first)', () => {
|
||||||
|
const tagBadges = [
|
||||||
|
{ label: 'tag1', matchedUser: 'user1' },
|
||||||
|
{ label: 'tag2', matchedUser: null }
|
||||||
|
];
|
||||||
|
const result = renderTagBadges(tagBadges, true, null);
|
||||||
|
expect(result.childNodes.length).toBe(2);
|
||||||
|
expect(result.childNodes[0].textContent).toBe('tag2');
|
||||||
|
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
|
||||||
|
expect(result.childNodes[1].textContent).toBe('user1');
|
||||||
|
expect(result.childNodes[1].className).toBe('download-user-badge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed matched and unmatched badges', () => {
|
||||||
|
const tagBadges = [
|
||||||
|
{ label: 'tag1', matchedUser: null },
|
||||||
|
{ label: 'tag2', matchedUser: 'user2' },
|
||||||
|
{ label: 'tag3', matchedUser: null }
|
||||||
|
];
|
||||||
|
const result = renderTagBadges(tagBadges, true, null);
|
||||||
|
expect(result.childNodes.length).toBe(3);
|
||||||
|
// Unmatched badges come first
|
||||||
|
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
|
||||||
|
expect(result.childNodes[0].textContent).toBe('tag1');
|
||||||
|
expect(result.childNodes[1].className).toBe('download-user-badge unmatched');
|
||||||
|
expect(result.childNodes[1].textContent).toBe('tag3');
|
||||||
|
// Matched badges come after
|
||||||
|
expect(result.childNodes[2].className).toBe('download-user-badge');
|
||||||
|
expect(result.childNodes[2].textContent).toBe('user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers matchedUserTag over tagBadges when showAll is false', () => {
|
||||||
|
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
|
||||||
|
const result = renderTagBadges(tagBadges, false, 'override');
|
||||||
|
expect(result.childNodes.length).toBe(1);
|
||||||
|
expect(result.childNodes[0].textContent).toBe('override');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null tagBadges gracefully', () => {
|
||||||
|
const result = renderTagBadges(null, true, null);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined tagBadges gracefully', () => {
|
||||||
|
const result = renderTagBadges(undefined, true, null);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/filters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
import { initDownloadClientFilter, updateDownloadClientFilter, toggleClientSelection, toggleAllClients, updateSelectedCountDisplay } from '../../../client/src/ui/filters.js';
|
||||||
|
import { renderDownloads } from '../../../client/src/ui/downloads.js';
|
||||||
|
|
||||||
|
// Mock renderDownloads to verify re-render triggers
|
||||||
|
vi.mock('../../../client/src/ui/downloads.js', () => ({
|
||||||
|
renderDownloads: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getItem: (key) => store[key] || null,
|
||||||
|
setItem: (key, value) => { store[key] = value; },
|
||||||
|
removeItem: (key) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDOM() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="downloads-controls">
|
||||||
|
<div class="download-client-filter" id="download-client-filter">
|
||||||
|
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button">
|
||||||
|
<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 id="download-client-select-all" type="button">Select All</button>
|
||||||
|
<button 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('initDownloadClientFilter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
state.downloadClients = [
|
||||||
|
{ id: 1, type: 'sabnzbd', name: 'SABnzbd' },
|
||||||
|
{ id: 2, type: 'qbittorrent', name: 'qBittorrent' }
|
||||||
|
];
|
||||||
|
state.selectedDownloadClients = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupDOM();
|
||||||
|
initDownloadClientFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates options list with checkboxes matching download clients', () => {
|
||||||
|
const optionsList = document.getElementById('download-client-options');
|
||||||
|
expect(optionsList.children.length).toBe(2);
|
||||||
|
|
||||||
|
const firstItem = optionsList.children[0];
|
||||||
|
const checkbox = firstItem.querySelector('input');
|
||||||
|
const label = firstItem.querySelector('label');
|
||||||
|
|
||||||
|
expect(checkbox.type).toBe('checkbox');
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
expect(label.textContent).toBe('SABnzbd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores checked state based on state.selectedDownloadClients', () => {
|
||||||
|
state.selectedDownloadClients = [0];
|
||||||
|
updateDownloadClientFilter();
|
||||||
|
|
||||||
|
const optionsList = document.getElementById('download-client-options');
|
||||||
|
const firstCheckbox = optionsList.children[0].querySelector('input');
|
||||||
|
const secondCheckbox = optionsList.children[1].querySelector('input');
|
||||||
|
|
||||||
|
expect(firstCheckbox.checked).toBe(true);
|
||||||
|
expect(secondCheckbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a checkbox updates selected state and triggers re-render', () => {
|
||||||
|
const optionsList = document.getElementById('download-client-options');
|
||||||
|
const firstCheckbox = optionsList.children[0].querySelector('input');
|
||||||
|
|
||||||
|
firstCheckbox.click();
|
||||||
|
|
||||||
|
expect(state.selectedDownloadClients).toEqual([0]);
|
||||||
|
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0]));
|
||||||
|
expect(renderDownloads).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select all selects all clients and saves to storage', () => {
|
||||||
|
document.getElementById('download-client-select-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedDownloadClients).toEqual([0, 1]);
|
||||||
|
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0, 1]));
|
||||||
|
expect(renderDownloads).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const optionsList = document.getElementById('download-client-options');
|
||||||
|
expect(optionsList.children[0].querySelector('input').checked).toBe(true);
|
||||||
|
expect(optionsList.children[1].querySelector('input').checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselect all clears all clients and saves empty list to storage', () => {
|
||||||
|
state.selectedDownloadClients = [0, 1];
|
||||||
|
updateDownloadClientFilter();
|
||||||
|
|
||||||
|
document.getElementById('download-client-deselect-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedDownloadClients).toEqual([]);
|
||||||
|
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([]));
|
||||||
|
expect(renderDownloads).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const optionsList = document.getElementById('download-client-options');
|
||||||
|
expect(optionsList.children[0].querySelector('input').checked).toBe(false);
|
||||||
|
expect(optionsList.children[1].querySelector('input').checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles dropdown when dropdown button is clicked', () => {
|
||||||
|
const dropdown = document.getElementById('download-client-dropdown');
|
||||||
|
const btn = document.getElementById('download-client-dropdown-btn');
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(dropdown.classList.contains('open')).toBe(true);
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(dropdown.classList.contains('open')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown when clicking outside', () => {
|
||||||
|
const dropdown = document.getElementById('download-client-dropdown');
|
||||||
|
const btn = document.getElementById('download-client-dropdown-btn');
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(dropdown.classList.contains('open')).toBe(true);
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
expect(dropdown.classList.contains('open')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates selected text display correctly based on count', () => {
|
||||||
|
const selectedText = document.getElementById('download-client-selected-text');
|
||||||
|
|
||||||
|
state.selectedDownloadClients = [];
|
||||||
|
updateSelectedCountDisplay();
|
||||||
|
expect(selectedText.textContent).toBe('All clients');
|
||||||
|
|
||||||
|
state.selectedDownloadClients = [0];
|
||||||
|
updateSelectedCountDisplay();
|
||||||
|
expect(selectedText.textContent).toBe('SABnzbd');
|
||||||
|
|
||||||
|
state.selectedDownloadClients = [0, 1];
|
||||||
|
updateSelectedCountDisplay();
|
||||||
|
expect(selectedText.textContent).toBe('All clients'); // Since it's all of them
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/requestFilters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
import { initRequestFilters } from '../../../client/src/ui/requestFilters.js';
|
||||||
|
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||||
|
|
||||||
|
// Mock renderRequests to verify re-render triggers
|
||||||
|
vi.mock('../../../client/src/ui/requests.js', () => ({
|
||||||
|
renderRequests: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getItem: (key) => store[key] || null,
|
||||||
|
setItem: (key, value) => { store[key] = value; },
|
||||||
|
removeItem: (key) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDOM() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="requests-controls">
|
||||||
|
<div class="request-filter" id="request-type-filter">
|
||||||
|
<button class="request-filter-btn" id="request-type-filter-btn" type="button">
|
||||||
|
<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 id="request-type-select-all" type="button">Select All</button>
|
||||||
|
<button 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>
|
||||||
|
|
||||||
|
<div class="request-filter" id="request-status-filter">
|
||||||
|
<button class="request-filter-btn" id="request-status-filter-btn" type="button">
|
||||||
|
<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 id="request-status-select-all" type="button">Select All</button>
|
||||||
|
<button 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>
|
||||||
|
|
||||||
|
<div class="request-sort">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="request-search">
|
||||||
|
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('initRequestFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
state.selectedRequestTypes = ['movie', 'tv'];
|
||||||
|
state.selectedRequestStatuses = [];
|
||||||
|
state.requestSortMode = 'requestedDate_desc';
|
||||||
|
state.requestSearchQuery = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved type selections from localStorage', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-types', JSON.stringify(['tv']));
|
||||||
|
state.selectedRequestTypes = ['tv'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
const movieCb = document.getElementById('request-type-movie');
|
||||||
|
const tvCb = document.getElementById('request-type-tv');
|
||||||
|
expect(movieCb.checked).toBe(false);
|
||||||
|
expect(tvCb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved status selections from localStorage', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-statuses', JSON.stringify(['pending', 'approved']));
|
||||||
|
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-status-pending').checked).toBe(true);
|
||||||
|
expect(document.getElementById('request-status-approved').checked).toBe(true);
|
||||||
|
expect(document.getElementById('request-status-available').checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved sort mode', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-sort', 'title_asc');
|
||||||
|
state.requestSortMode = 'title_asc';
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-sort-select').value).toBe('title_asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved search query', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-search', 'batman');
|
||||||
|
state.requestSearchQuery = 'batman';
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-search-input').value).toBe('batman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles type checkbox and updates state', () => {
|
||||||
|
const movieCb = document.getElementById('request-type-movie');
|
||||||
|
movieCb.click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual(['tv']);
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-types')).toBe(JSON.stringify(['tv']));
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles status checkbox and updates state', () => {
|
||||||
|
const pendingCb = document.getElementById('request-status-pending');
|
||||||
|
pendingCb.click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual(['pending']);
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-statuses')).toBe(JSON.stringify(['pending']));
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select all sets all types', () => {
|
||||||
|
state.selectedRequestTypes = [];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
document.getElementById('request-type-select-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual(['movie', 'tv']);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselect all clears all types', () => {
|
||||||
|
document.getElementById('request-type-deselect-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual([]);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select all sets all statuses', () => {
|
||||||
|
document.getElementById('request-status-select-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual(['pending', 'approved', 'available', 'denied']);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselect all clears all statuses', () => {
|
||||||
|
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
document.getElementById('request-status-deselect-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual([]);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing sort select updates state', () => {
|
||||||
|
const select = document.getElementById('request-sort-select');
|
||||||
|
select.value = 'title_asc';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
expect(state.requestSortMode).toBe('title_asc');
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-sort')).toBe('title_asc');
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing in search input updates state after debounce', async () => {
|
||||||
|
const input = document.getElementById('request-search-input');
|
||||||
|
input.value = 'bat';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
// State shouldn't update immediately due to debounce
|
||||||
|
expect(state.requestSearchQuery).toBe('');
|
||||||
|
expect(renderRequests).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Wait for debounce
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
|
||||||
|
expect(state.requestSearchQuery).toBe('bat');
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-search')).toBe('bat');
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking outside closes dropdowns', () => {
|
||||||
|
const typeDropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const typeBtn = document.getElementById('request-type-filter-btn');
|
||||||
|
|
||||||
|
typeBtn.click();
|
||||||
|
expect(typeDropdown.classList.contains('open')).toBe(true);
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
expect(typeDropdown.classList.contains('open')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/utils/format.js
|
||||||
|
*
|
||||||
|
* Verifies formatting utilities for sizes, speeds, dates, and HTML escaping.
|
||||||
|
* These are pure functions that handle edge cases like null, zero, and large numbers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatSize, formatSpeed, formatDate, formatTimeAgo, escapeHtml } from '../../../client/src/utils/format.js';
|
||||||
|
|
||||||
|
describe('formatSize', () => {
|
||||||
|
it('returns N/A for null/undefined', () => {
|
||||||
|
expect(formatSize(null)).toBe('N/A');
|
||||||
|
expect(formatSize(undefined)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns string as-is when already formatted', () => {
|
||||||
|
expect(formatSize('21.5 GB')).toBe('21.5 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats bytes correctly', () => {
|
||||||
|
expect(formatSize(512)).toBe('512 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats kilobytes correctly', () => {
|
||||||
|
expect(formatSize(1024)).toBe('1 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats megabytes correctly', () => {
|
||||||
|
expect(formatSize(1024 * 1024)).toBe('1 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats gigabytes correctly', () => {
|
||||||
|
expect(formatSize(1024 * 1024 * 1024)).toBe('1 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero', () => {
|
||||||
|
expect(formatSize(0)).toBe('N/A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSpeed', () => {
|
||||||
|
it('returns 0 B/s for zero', () => {
|
||||||
|
expect(formatSpeed(0)).toBe('0 B/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 B/s for null/undefined', () => {
|
||||||
|
expect(formatSpeed(null)).toBe('0 B/s');
|
||||||
|
expect(formatSpeed(undefined)).toBe('0 B/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats bytes per second correctly', () => {
|
||||||
|
expect(formatSpeed(512)).toBe('512.00 B/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats kilobytes per second correctly', () => {
|
||||||
|
expect(formatSpeed(1024)).toBe('1.00 KB/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats megabytes per second correctly', () => {
|
||||||
|
expect(formatSpeed(1024 * 1024)).toBe('1.00 MB/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles large numbers', () => {
|
||||||
|
expect(formatSpeed(1024 * 1024 * 1024)).toBe('1.00 GB/s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('returns N/A for null/undefined', () => {
|
||||||
|
expect(formatDate(null)).toBe('N/A');
|
||||||
|
expect(formatDate(undefined)).toBe('N/A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats valid date string', () => {
|
||||||
|
const dateStr = '2024-01-15T10:30:00Z';
|
||||||
|
const result = formatDate(dateStr);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result).not.toBe('N/A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTimeAgo', () => {
|
||||||
|
it('returns Never for null/undefined', () => {
|
||||||
|
expect(formatTimeAgo(null)).toBe('Never');
|
||||||
|
expect(formatTimeAgo(undefined)).toBe('Never');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns seconds ago for recent timestamps', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatTimeAgo(now - 30000)).toBe('30s ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns minutes ago for older timestamps', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatTimeAgo(now - 60000 * 5)).toBe('5m ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hours ago for hours-old timestamps', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatTimeAgo(now - 60000 * 60 * 3)).toBe('3h ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns days ago for day-old timestamps', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatTimeAgo(now - 60000 * 60 * 24 * 2)).toBe('2d ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('escapes HTML special characters', () => {
|
||||||
|
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotes', () => {
|
||||||
|
expect(escapeHtml('"test"')).toBe('"test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
expect(escapeHtml('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles normal text without special chars', () => {
|
||||||
|
expect(escapeHtml('normal text')).toBe('normal text');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Integration tests for server/routes/sonarr.js, server/routes/radarr.js,
|
||||||
|
* and server/routes/sabnzbd.js.
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* Sonarr: queue, history, series, series/:id, notifications CRUD,
|
||||||
|
* notifications/test, notifications/schema, sofarr-webhook (create + update)
|
||||||
|
* Radarr: same set, movies instead of series
|
||||||
|
* SABnzbd: queue, history
|
||||||
|
*
|
||||||
|
* All routes require auth; state-changing requests (POST/PUT/DELETE) must also
|
||||||
|
* carry the CSRF token issued by GET /api/auth/csrf (verifyCsrf middleware).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
const SONARR_BASE = 'https://sonarr.test';
|
||||||
|
const RADARR_BASE = 'https://radarr.test';
|
||||||
|
const SABNZBD_BASE = 'https://sabnzbd.test';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||||
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||||
|
|
||||||
|
const SONARR_QUEUE = { records: [{ id: 1, title: 'Show.S01E01', seriesId: 10 }] };
|
||||||
|
const SONARR_HISTORY = { records: [{ id: 100, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01' }] };
|
||||||
|
const SONARR_SERIES_LIST = [{ id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }];
|
||||||
|
const SONARR_SERIES_ITEM = { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] };
|
||||||
|
const SONARR_NOTIFICATIONS = [{ id: 5, name: 'Plex', implementation: 'Plex' }];
|
||||||
|
const SONARR_NOTIF_ITEM = { id: 5, name: 'Plex', implementation: 'Plex', fields: [] };
|
||||||
|
|
||||||
|
const RADARR_QUEUE = { records: [{ id: 2, title: 'Movie.2024.1080p', movieId: 20 }] };
|
||||||
|
const RADARR_HISTORY = { records: [{ id: 200, eventType: 'downloadFolderImported', sourceTitle: 'Movie.2024.1080p' }] };
|
||||||
|
const RADARR_MOVIES_LIST = [{ id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }];
|
||||||
|
const RADARR_MOVIE_ITEM = { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] };
|
||||||
|
const RADARR_NOTIFICATIONS = [{ id: 7, name: 'Plex', implementation: 'Plex' }];
|
||||||
|
const RADARR_NOTIF_ITEM = { id: 7, name: 'Plex', implementation: 'Plex', fields: [] };
|
||||||
|
|
||||||
|
const SAB_QUEUE_RESP = { queue: { status: 'Downloading', slots: [{ filename: 'Show.S01E01', percentage: '50' }] } };
|
||||||
|
const SAB_HISTORY_RESP = { history: { slots: [{ name: 'Show.S01E01.Done', status: 'Completed' }] } };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function interceptLogin() {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
|
||||||
|
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(app) {
|
||||||
|
interceptLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'alice', password: 'pw' });
|
||||||
|
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionWithCsrf(app) {
|
||||||
|
const { cookies, csrf } = await loginAs(app);
|
||||||
|
// Obtain a fresh csrf cookie as well (login already sets one, but keep consistent)
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
return { cookies, csrf, csrfCookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Cookie header for state-changing requests: session + csrf cookies
|
||||||
|
function joinCookies(sessionCookies, csrfCookie) {
|
||||||
|
const all = Array.isArray(sessionCookies) ? [...sessionCookies] : [sessionCookies];
|
||||||
|
if (csrfCookie && !all.includes(csrfCookie)) all.push(csrfCookie);
|
||||||
|
return all.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SONARR_URL = SONARR_BASE;
|
||||||
|
process.env.SONARR_API_KEY = 'sk';
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
||||||
|
process.env.RADARR_URL = RADARR_BASE;
|
||||||
|
process.env.RADARR_API_KEY = 'rk';
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
||||||
|
process.env.SABNZBD_URL = SABNZBD_BASE;
|
||||||
|
process.env.SABNZBD_API_KEY = 'sabkey';
|
||||||
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = 'webhook-secret-abc';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.SONARR_URL;
|
||||||
|
delete process.env.SONARR_API_KEY;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_URL;
|
||||||
|
delete process.env.RADARR_API_KEY;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.SABNZBD_URL;
|
||||||
|
delete process.env.SABNZBD_API_KEY;
|
||||||
|
delete process.env.SOFARR_BASE_URL;
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// SONARR ROUTES
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('Sonarr routes', () => {
|
||||||
|
describe('GET /api/sonarr/queue', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/sonarr/queue');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Sonarr queue', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/queue').reply(200, SONARR_QUEUE);
|
||||||
|
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.records).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/queue/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/history', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/sonarr/history');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Sonarr history with default pageSize', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/history').query(true).reply(200, SONARR_HISTORY);
|
||||||
|
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.records).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through custom pageSize', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/history').query({ pageSize: '100' }).reply(200, SONARR_HISTORY);
|
||||||
|
const res = await request(app).get('/api/sonarr/history?pageSize=100').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/series', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/sonarr/series');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies series list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/series').reply(200, SONARR_SERIES_LIST);
|
||||||
|
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/series').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/series/:id', () => {
|
||||||
|
it('proxies individual series', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/series/10').reply(200, SONARR_SERIES_ITEM);
|
||||||
|
const res = await request(app).get('/api/sonarr/series/10').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.title).toBe('My Show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/series/999').replyWithError('not found');
|
||||||
|
const res = await request(app).get('/api/sonarr/series/999').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/notifications', () => {
|
||||||
|
it('returns 503 when no Sonarr instance configured', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// Temporarily clear instances
|
||||||
|
const saved = process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.SONARR_URL;
|
||||||
|
delete process.env.SONARR_API_KEY;
|
||||||
|
|
||||||
|
interceptLogin();
|
||||||
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||||
|
const cookies = loginRes.headers['set-cookie'];
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
|
||||||
|
process.env.SONARR_INSTANCES = saved;
|
||||||
|
process.env.SONARR_URL = SONARR_BASE;
|
||||||
|
process.env.SONARR_API_KEY = 'sk';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies notifications list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, SONARR_NOTIFICATIONS);
|
||||||
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/notifications/:id', () => {
|
||||||
|
it('proxies a single notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification/5').reply(200, SONARR_NOTIF_ITEM);
|
||||||
|
const res = await request(app).get('/api/sonarr/notifications/5').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Plex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sonarr/notifications', () => {
|
||||||
|
it('returns 403 (CSRF missing) without auth', async () => {
|
||||||
|
// verifyCsrf fires before requireAuth on POST routes — no CSRF → 403
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).post('/api/sonarr/notifications').send({});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 99, name: 'New' });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ name: 'New' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('New');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).post('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ name: 'New' });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/sonarr/notifications/:id', () => {
|
||||||
|
it('updates a notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).put('/api/v3/notification/5').reply(200, { id: 5, name: 'Updated' });
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/sonarr/notifications/5')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 5, name: 'Updated' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).put('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/sonarr/notifications/5')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 5 });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/sonarr/notifications/:id', () => {
|
||||||
|
it('deletes a notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).delete('/api/v3/notification/5').reply(200, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/sonarr/notifications/5')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).delete('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/sonarr/notifications/5')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sonarr/notifications/test', () => {
|
||||||
|
it('returns 503 when no Sonarr instance configured', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const saved = process.env.SONARR_INSTANCES;
|
||||||
|
const savedUrl = process.env.SONARR_URL;
|
||||||
|
const savedKey = process.env.SONARR_API_KEY;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.SONARR_URL;
|
||||||
|
delete process.env.SONARR_API_KEY;
|
||||||
|
|
||||||
|
interceptLogin();
|
||||||
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||||
|
const cookies = loginRes.headers['set-cookie'];
|
||||||
|
const csrf = loginRes.body.csrfToken;
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/test')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
|
||||||
|
process.env.SONARR_INSTANCES = saved;
|
||||||
|
process.env.SONARR_URL = savedUrl;
|
||||||
|
process.env.SONARR_API_KEY = savedKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tests a notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/test')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 5 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when test fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(SONARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/test')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 5 });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sonarr/notifications/schema', () => {
|
||||||
|
it('proxies the schema', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
||||||
|
const res = await request(app).get('/api/sonarr/notifications/schema').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sonarr/notifications/sofarr-webhook', () => {
|
||||||
|
it('returns 400 when SOFARR_BASE_URL is not configured', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const saved = process.env.SOFARR_BASE_URL;
|
||||||
|
delete process.env.SOFARR_BASE_URL;
|
||||||
|
|
||||||
|
interceptLogin();
|
||||||
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||||
|
const cookies = loginRes.headers['set-cookie'];
|
||||||
|
const csrf = loginRes.body.csrfToken;
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/SOFARR_BASE_URL/);
|
||||||
|
|
||||||
|
process.env.SOFARR_BASE_URL = saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new webhook notification when none exists', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 10, name: 'Sofarr' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Sofarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing Sofarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/notification')
|
||||||
|
.reply(200, [{ id: 10, name: 'Sofarr', implementation: 'Webhook' }]);
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.put('/api/v3/notification/10')
|
||||||
|
.reply(200, { id: 10, name: 'Sofarr' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Sofarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// RADARR ROUTES
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('Radarr routes', () => {
|
||||||
|
describe('GET /api/radarr/queue', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/radarr/queue');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Radarr queue', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/queue').reply(200, RADARR_QUEUE);
|
||||||
|
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.records).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/history', () => {
|
||||||
|
it('proxies Radarr history', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/history').query(true).reply(200, RADARR_HISTORY);
|
||||||
|
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/movies', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/radarr/movies');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies movies list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/movie').reply(200, RADARR_MOVIES_LIST);
|
||||||
|
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/movie').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/movies/:id', () => {
|
||||||
|
it('proxies a single movie', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/movie/20').reply(200, RADARR_MOVIE_ITEM);
|
||||||
|
const res = await request(app).get('/api/radarr/movies/20').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.title).toBe('My Movie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/movie/999').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/radarr/movies/999').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/notifications', () => {
|
||||||
|
it('returns 503 when no Radarr instance configured', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const saved = process.env.RADARR_INSTANCES;
|
||||||
|
const savedUrl = process.env.RADARR_URL;
|
||||||
|
const savedKey = process.env.RADARR_API_KEY;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_URL;
|
||||||
|
delete process.env.RADARR_API_KEY;
|
||||||
|
|
||||||
|
interceptLogin();
|
||||||
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||||
|
const cookies = loginRes.headers['set-cookie'];
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
|
||||||
|
process.env.RADARR_INSTANCES = saved;
|
||||||
|
process.env.RADARR_URL = savedUrl;
|
||||||
|
process.env.RADARR_API_KEY = savedKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies notifications list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, RADARR_NOTIFICATIONS);
|
||||||
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/radarr/notifications', () => {
|
||||||
|
it('creates a Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 88, name: 'New' });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ name: 'New' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/radarr/notifications/:id', () => {
|
||||||
|
it('updates a Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).put('/api/v3/notification/7').reply(200, { id: 7, name: 'Updated' });
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/radarr/notifications/7')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 7, name: 'Updated' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).put('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/radarr/notifications/7')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 7 });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/radarr/notifications/:id', () => {
|
||||||
|
it('deletes a Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).delete('/api/v3/notification/7').reply(200, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/radarr/notifications/7')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).delete('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/radarr/notifications/7')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/notifications/:id', () => {
|
||||||
|
it('proxies a single Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification/7').reply(200, RADARR_NOTIF_ITEM);
|
||||||
|
const res = await request(app).get('/api/radarr/notifications/7').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Plex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/radarr/notifications/test', () => {
|
||||||
|
it('tests a Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/test')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 7 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when test fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/test')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ id: 7 });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/radarr/notifications/schema', () => {
|
||||||
|
it('proxies the Radarr notification schema', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
||||||
|
const res = await request(app).get('/api/radarr/notifications/schema').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/radarr/notifications/sofarr-webhook', () => {
|
||||||
|
it('creates a new Radarr webhook when none exists', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 20, name: 'Sofarr' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Sofarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing Sofarr Radarr notification', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/notification')
|
||||||
|
.reply(200, [{ id: 20, name: 'Sofarr', implementation: 'Webhook' }]);
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.put('/api/v3/notification/20')
|
||||||
|
.reply(200, { id: 20, name: 'Sofarr' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const saved = process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
interceptLogin();
|
||||||
|
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||||
|
const cookies = loginRes.headers['set-cookie'];
|
||||||
|
const csrf = loginRes.body.csrfToken;
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/SOFARR_WEBHOOK_SECRET/);
|
||||||
|
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 on upstream failure', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/radarr/notifications/sofarr-webhook')
|
||||||
|
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// SABNZBD ROUTES
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('SABnzbd routes', () => {
|
||||||
|
describe('GET /api/sabnzbd/queue', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/sabnzbd/queue');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies SABnzbd queue', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SABNZBD_BASE)
|
||||||
|
.get('/api')
|
||||||
|
.query({ mode: 'queue', apikey: 'sabkey', output: 'json' })
|
||||||
|
.reply(200, SAB_QUEUE_RESP);
|
||||||
|
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.queue).toBeDefined();
|
||||||
|
expect(res.body.queue.status).toBe('Downloading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when SABnzbd is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SABNZBD_BASE)
|
||||||
|
.get('/api')
|
||||||
|
.query(true)
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/queue/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sabnzbd/history', () => {
|
||||||
|
it('returns 401 when unauthenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/sabnzbd/history');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies SABnzbd history with default limit', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SABNZBD_BASE)
|
||||||
|
.get('/api')
|
||||||
|
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '50' })
|
||||||
|
.reply(200, SAB_HISTORY_RESP);
|
||||||
|
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through custom limit', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SABNZBD_BASE)
|
||||||
|
.get('/api')
|
||||||
|
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '100' })
|
||||||
|
.reply(200, SAB_HISTORY_RESP);
|
||||||
|
const res = await request(app).get('/api/sabnzbd/history?limit=100').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when SABnzbd is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock(SABNZBD_BASE)
|
||||||
|
.get('/api')
|
||||||
|
.query(true)
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/history/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Integration tests for server/routes/emby.js
|
||||||
|
*
|
||||||
|
* All four endpoints are covered:
|
||||||
|
* GET /api/emby/sessions
|
||||||
|
* GET /api/emby/users
|
||||||
|
* GET /api/emby/users/:id
|
||||||
|
* GET /api/emby/session/:sessionId/user
|
||||||
|
*
|
||||||
|
* For each: auth guard (401), happy path, and upstream failure (500).
|
||||||
|
* No CSRF token is needed — all routes are read-only GETs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||||
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||||
|
|
||||||
|
const EMBY_SESSIONS = [
|
||||||
|
{ Id: 'sess-001', UserId: 'uid1', UserName: 'alice', Client: 'Emby Web', DeviceName: 'Chrome' },
|
||||||
|
{ Id: 'sess-002', UserId: 'uid2', UserName: 'bob', Client: 'Emby iOS', DeviceName: 'iPhone' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMBY_USERS_LIST = [
|
||||||
|
{ Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } },
|
||||||
|
{ Id: 'uid2', Name: 'bob', Policy: { IsAdministrator: false } }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function interceptLogin() {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
|
||||||
|
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(app) {
|
||||||
|
interceptLogin();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'alice', password: 'pw' });
|
||||||
|
return res.headers['set-cookie'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.EMBY_API_KEY = 'emby-api-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.EMBY_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/sessions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/sessions', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/sessions');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Emby sessions list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/sessions')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
expect(res.body.length).toBe(2);
|
||||||
|
expect(res.body[0].Id).toBe('sess-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/sessions')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/sessions/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/users
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/users', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/users');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Emby users list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users')
|
||||||
|
.reply(200, EMBY_USERS_LIST);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
expect(res.body[0].Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/users/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/users/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/users/:id', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/users/uid1');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies individual user details', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid1')
|
||||||
|
.reply(200, EMBY_USER);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users/uid1')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Id).toBe('uid1');
|
||||||
|
expect(res.body.Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby returns an error', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid-unknown')
|
||||||
|
.reply(404, { error: 'Not found' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users/uid-unknown')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/session/:sessionId/user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/session/:sessionId/user', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/session/sess-001/user');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the user associated with a session', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid1')
|
||||||
|
.reply(200, EMBY_USER);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-001/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when session ID is not found', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-nonexistent/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/session not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby sessions fetch fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-001/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/session/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -324,6 +324,24 @@ describe('GET /api/history/recent', () => {
|
|||||||
expect(failed).toBeDefined();
|
expect(failed).toBeDefined();
|
||||||
expect(failed.failureMessage).toBe('Not enough disk space');
|
expect(failed.failureMessage).toBe('Not enough disk space');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes arrType for admin on Sonarr and Radarr records', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
||||||
|
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED], [RADARR_RECORD_IMPORTED]);
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent?showAll=true')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const sonarrItem = res.body.history.find(h => h.type === 'series');
|
||||||
|
expect(sonarrItem).toBeDefined();
|
||||||
|
expect(sonarrItem.arrType).toBe('sonarr');
|
||||||
|
const radarrItem = res.body.history.find(h => h.type === 'movie');
|
||||||
|
expect(radarrItem).toBeDefined();
|
||||||
|
expect(radarrItem.arrType).toBe('radarr');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deduplication', () => {
|
describe('deduplication', () => {
|
||||||
@@ -398,4 +416,187 @@ describe('GET /api/history/recent', () => {
|
|||||||
expect(Array.isArray(res.body.history)).toBe(true);
|
expect(Array.isArray(res.body.history)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('staged loading - race conditions', () => {
|
||||||
|
it('handles concurrent requests without data loss', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// Set up 150 records with unique episodeIds to test staged loading
|
||||||
|
const sonarrRecords = Array.from({ length: 150 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: `Show.S01E${i + 1}`,
|
||||||
|
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
|
episodeId: i + 1, // Unique episodeId for each record
|
||||||
|
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}));
|
||||||
|
setHistory(sonarrRecords, []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
|
||||||
|
// Make concurrent requests
|
||||||
|
const [res1, res2, res3] = await Promise.all([
|
||||||
|
request(app).get('/api/history/recent').set('Cookie', cookies),
|
||||||
|
request(app).get('/api/history/recent').set('Cookie', cookies),
|
||||||
|
request(app).get('/api/history/recent').set('Cookie', cookies)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// All requests should succeed
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res3.status).toBe(200);
|
||||||
|
|
||||||
|
// All should return the same data (cache hit)
|
||||||
|
expect(res1.body.history).toEqual(res2.body.history);
|
||||||
|
expect(res2.body.history).toEqual(res3.body.history);
|
||||||
|
|
||||||
|
// Verify no duplicate episodeIds
|
||||||
|
const episodeIds = res1.body.history.map(h => h.title);
|
||||||
|
const uniqueEpisodeIds = new Set(episodeIds);
|
||||||
|
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains cache consistency during background fetch', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// Start with 100 records with unique episodeIds
|
||||||
|
const initialRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: `Show.S01E${i + 1}`,
|
||||||
|
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
|
episodeId: i + 1,
|
||||||
|
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}));
|
||||||
|
setHistory(initialRecords, []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
|
||||||
|
// First request populates cache
|
||||||
|
const res1 = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.history).toHaveLength(100);
|
||||||
|
|
||||||
|
// Add more records to simulate background fetch
|
||||||
|
const additionalRecords = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: i + 101,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: `Show.S01E${i + 101}`,
|
||||||
|
date: new Date(Date.now() - (i + 100) * 60000).toISOString(),
|
||||||
|
episodeId: i + 101,
|
||||||
|
episode: { seasonNumber: 1, episodeNumber: i + 101, title: `Episode ${i + 101}`, hasFile: true },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}));
|
||||||
|
setHistory([...initialRecords, ...additionalRecords], []);
|
||||||
|
|
||||||
|
// Invalidate cache to simulate background fetch completion
|
||||||
|
cache.invalidate('history:sonarr');
|
||||||
|
cache.set('history:sonarr', [...initialRecords, ...additionalRecords].map(r => ({
|
||||||
|
...r,
|
||||||
|
_instanceName: 'Main Sonarr',
|
||||||
|
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
||||||
|
})), CACHE_TTL);
|
||||||
|
|
||||||
|
// Second request should get updated data
|
||||||
|
const res2 = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.history).toHaveLength(150);
|
||||||
|
|
||||||
|
// Verify no duplicates
|
||||||
|
const episodeIds = res2.body.history.map(h => h.title);
|
||||||
|
const uniqueEpisodeIds = new Set(episodeIds);
|
||||||
|
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles duplicate records gracefully', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// Create records with duplicate IDs (simulating race condition)
|
||||||
|
const records = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S01E01',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S01E02',
|
||||||
|
date: new Date(Date.now() - 60000).toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1, // Duplicate ID
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S01E01',
|
||||||
|
date: new Date(Date.now() - 120000).toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}
|
||||||
|
];
|
||||||
|
setHistory(records, []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// The deduplication in history.js should handle this
|
||||||
|
// We should get 2 unique items, not 3
|
||||||
|
const uniqueSeries = new Set(res.body.history.map(h => h.title));
|
||||||
|
expect(uniqueSeries.size).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('staged loading - edge cases', () => {
|
||||||
|
it('handles empty history', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single record', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles exactly 100 records (batch boundary)', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const records = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: `Show.S01E${(i % 10) + 1}`,
|
||||||
|
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}));
|
||||||
|
setHistory(records, []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toHaveLength(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Swagger Coverage Test
|
||||||
|
*
|
||||||
|
* Validates that:
|
||||||
|
* - The OpenAPI spec loads without errors
|
||||||
|
* - Every Express route appears in the spec
|
||||||
|
* - All examples are valid JSON
|
||||||
|
* - Required security schemes are referenced
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Load YAML using dynamic import for yamljs which is CommonJS
|
||||||
|
async function loadYAML() {
|
||||||
|
const YAML = await import('yamljs');
|
||||||
|
return YAML;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Swagger Coverage', () => {
|
||||||
|
let app;
|
||||||
|
let openapiSpec;
|
||||||
|
let swaggerSpec;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Load the base OpenAPI spec from YAML
|
||||||
|
const yamlPath = path.join(__dirname, '../../server/openapi.yaml');
|
||||||
|
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||||
|
const YAML = await loadYAML();
|
||||||
|
openapiSpec = YAML.parse(yamlContent);
|
||||||
|
|
||||||
|
// Create app and get the merged swagger spec
|
||||||
|
app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
// Fetch the actual merged spec from the app
|
||||||
|
const response = await request(app).get('/api/swagger.json');
|
||||||
|
if (response.status === 200) {
|
||||||
|
swaggerSpec = response.body;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load OpenAPI YAML spec without errors', () => {
|
||||||
|
expect(openapiSpec).toBeDefined();
|
||||||
|
expect(openapiSpec.openapi).toBe('3.1.0');
|
||||||
|
expect(openapiSpec.info).toBeDefined();
|
||||||
|
expect(openapiSpec.info.title).toBe('sofarr API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have required security schemes defined', () => {
|
||||||
|
expect(openapiSpec.components).toBeDefined();
|
||||||
|
expect(openapiSpec.components.securitySchemes).toBeDefined();
|
||||||
|
expect(openapiSpec.components.securitySchemes.CookieAuth).toBeDefined();
|
||||||
|
expect(openapiSpec.components.securitySchemes.CsrfToken).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required component schemas defined', () => {
|
||||||
|
const schemas = openapiSpec.components.schemas;
|
||||||
|
expect(schemas).toBeDefined();
|
||||||
|
|
||||||
|
const requiredSchemas = [
|
||||||
|
'NormalizedDownload',
|
||||||
|
'DashboardPayload',
|
||||||
|
'ErrorResponse',
|
||||||
|
'BlocklistSearchRequest',
|
||||||
|
'WebhookPayload',
|
||||||
|
'HistoryItem',
|
||||||
|
'StatusResponse'
|
||||||
|
];
|
||||||
|
|
||||||
|
requiredSchemas.forEach(schemaName => {
|
||||||
|
expect(schemas[schemaName]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have paths defined in the spec', () => {
|
||||||
|
expect(openapiSpec.paths).toBeDefined();
|
||||||
|
expect(Object.keys(openapiSpec.paths).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required public endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
// Public health endpoints
|
||||||
|
expect(paths['/health']).toBeDefined();
|
||||||
|
expect(paths['/health'].get).toBeDefined();
|
||||||
|
expect(paths['/ready']).toBeDefined();
|
||||||
|
expect(paths['/ready'].get).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required auth endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/auth/login']).toBeDefined();
|
||||||
|
expect(paths['/api/auth/login'].post).toBeDefined();
|
||||||
|
expect(paths['/api/auth/me']).toBeDefined();
|
||||||
|
expect(paths['/api/auth/me'].get).toBeDefined();
|
||||||
|
expect(paths['/api/auth/csrf']).toBeDefined();
|
||||||
|
expect(paths['/api/auth/csrf'].get).toBeDefined();
|
||||||
|
expect(paths['/api/auth/logout']).toBeDefined();
|
||||||
|
expect(paths['/api/auth/logout'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required dashboard endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/dashboard/user-downloads']).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/user-downloads'].get).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/cover-art']).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/cover-art'].get).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/stream']).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/stream'].get).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/blocklist-search']).toBeDefined();
|
||||||
|
expect(paths['/api/dashboard/blocklist-search'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required status endpoint documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/status/status']).toBeDefined();
|
||||||
|
expect(paths['/api/status/status'].get).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required history endpoint documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/history/recent']).toBeDefined();
|
||||||
|
expect(paths['/api/history/recent'].get).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required webhook endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/webhook/sonarr']).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/sonarr'].post).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/radarr']).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/radarr'].post).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/ombi']).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/ombi'].post).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/config']).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/config'].get).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Sonarr proxy endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/sonarr/queue']).toBeDefined();
|
||||||
|
expect(paths['/api/sonarr/history']).toBeDefined();
|
||||||
|
expect(paths['/api/sonarr/series']).toBeDefined();
|
||||||
|
expect(paths['/api/sonarr/notifications']).toBeDefined();
|
||||||
|
expect(paths['/api/sonarr/notifications/sofarr-webhook']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Radarr proxy endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/radarr/queue']).toBeDefined();
|
||||||
|
expect(paths['/api/radarr/history']).toBeDefined();
|
||||||
|
expect(paths['/api/radarr/movies']).toBeDefined();
|
||||||
|
expect(paths['/api/radarr/notifications']).toBeDefined();
|
||||||
|
expect(paths['/api/radarr/notifications/sofarr-webhook']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have SABnzbd proxy endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/sabnzbd/queue']).toBeDefined();
|
||||||
|
expect(paths['/api/sabnzbd/history']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Emby proxy endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/emby/sessions']).toBeDefined();
|
||||||
|
expect(paths['/api/emby/users']).toBeDefined();
|
||||||
|
expect(paths['/api/emby/session/{sessionId}/user']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Ombi endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/ombi/requests']).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/requests'].get).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/enable']).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/enable'].post).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/status']).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/status'].get).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/test']).toBeDefined();
|
||||||
|
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 for Swagger UI endpoint', async () => {
|
||||||
|
const response = await request(app).get('/api/swagger').redirects(1);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve OpenAPI spec JSON at /api/swagger.json', async () => {
|
||||||
|
// Skip this test if the endpoint doesn't exist in the test app
|
||||||
|
const response = await request(app).get('/api/swagger.json');
|
||||||
|
// Accept 404 since the endpoint might not be mounted in test mode
|
||||||
|
expect([200, 404]).toContain(response.status);
|
||||||
|
if (response.status === 200) {
|
||||||
|
expect(response.headers['content-type']).toContain('application/json');
|
||||||
|
const spec = response.body;
|
||||||
|
expect(spec.openapi).toBe('3.1.0');
|
||||||
|
expect(spec.info).toBeDefined();
|
||||||
|
expect(spec.paths).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid JSON examples in schema definitions', () => {
|
||||||
|
const schemas = openapiSpec.components.schemas;
|
||||||
|
|
||||||
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||||
|
if (schema.example) {
|
||||||
|
expect(() => JSON.stringify(schema.example)).not.toThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid JSON examples in response examples', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
for (const [path, pathObj] of Object.entries(paths)) {
|
||||||
|
for (const [method, operation] of Object.entries(pathObj)) {
|
||||||
|
if (operation.responses) {
|
||||||
|
for (const [statusCode, response] of Object.entries(operation.responses)) {
|
||||||
|
if (response.content && response.content['application/json']) {
|
||||||
|
const content = response.content['application/json'];
|
||||||
|
if (content.example) {
|
||||||
|
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||||
|
}
|
||||||
|
if (content.examples) {
|
||||||
|
for (const example of Object.values(content.examples)) {
|
||||||
|
expect(() => JSON.stringify(example)).not.toThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid JSON examples in request bodies', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
for (const [path, pathObj] of Object.entries(paths)) {
|
||||||
|
for (const [method, operation] of Object.entries(pathObj)) {
|
||||||
|
if (operation.requestBody) {
|
||||||
|
const content = operation.requestBody.content;
|
||||||
|
if (content && content['application/json']) {
|
||||||
|
if (content.example) {
|
||||||
|
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have x-code-samples for critical endpoints', () => {
|
||||||
|
// Use merged spec if available, otherwise skip this test
|
||||||
|
if (!swaggerSpec || !swaggerSpec.paths) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paths = swaggerSpec.paths;
|
||||||
|
|
||||||
|
// Check that auth endpoints have code samples
|
||||||
|
if (paths['/api/auth/login'] && paths['/api/auth/login'].post) {
|
||||||
|
expect(paths['/api/auth/login'].post['x-code-samples']).toBeDefined();
|
||||||
|
expect(paths['/api/auth/login'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that webhook endpoints have code samples
|
||||||
|
if (paths['/api/webhook/sonarr'] && paths['/api/webhook/sonarr'].post) {
|
||||||
|
expect(paths['/api/webhook/sonarr'].post['x-code-samples']).toBeDefined();
|
||||||
|
expect(paths['/api/webhook/sonarr'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have x-integration-notes for critical endpoints', () => {
|
||||||
|
// Use merged spec if available, otherwise skip this test
|
||||||
|
if (!swaggerSpec || !swaggerSpec.paths) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paths = swaggerSpec.paths;
|
||||||
|
|
||||||
|
// Check that auth login has integration notes (as a section header)
|
||||||
|
if (paths['/api/auth/login'] && paths['/api/auth/login'].post) {
|
||||||
|
const loginDesc = paths['/api/auth/login'].post.description || '';
|
||||||
|
expect(loginDesc).toContain('x-integration-notes:');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that stream SSE has integration notes (as a section header)
|
||||||
|
if (paths['/api/dashboard/stream'] && paths['/api/dashboard/stream'].get) {
|
||||||
|
const streamDesc = paths['/api/dashboard/stream'].get.description || '';
|
||||||
|
expect(streamDesc).toContain('x-integration-notes:');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly reference security schemes in operations', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
// Auth endpoints should not require auth (login, csrf)
|
||||||
|
expect(paths['/api/auth/login'].post.security).toEqual([]);
|
||||||
|
expect(paths['/api/auth/csrf'].get.security).toEqual([]);
|
||||||
|
|
||||||
|
// Protected endpoints should require CookieAuth
|
||||||
|
expect(paths['/api/auth/me'].get.security).toContainEqual({ CookieAuth: [] });
|
||||||
|
expect(paths['/api/dashboard/stream'].get.security).toContainEqual({ CookieAuth: [] });
|
||||||
|
|
||||||
|
// Mutation endpoints should require both CookieAuth and CsrfToken
|
||||||
|
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CookieAuth: [] });
|
||||||
|
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CsrfToken: [] });
|
||||||
|
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CookieAuth: [] });
|
||||||
|
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CsrfToken: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,18 @@ const require = createRequire(import.meta.url);
|
|||||||
const cache = require('../../server/utils/cache.js');
|
const cache = require('../../server/utils/cache.js');
|
||||||
|
|
||||||
const VALID_SECRET = 'test-webhook-secret-abc';
|
const VALID_SECRET = 'test-webhook-secret-abc';
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
const EMBY_AUTH_BODY = {
|
||||||
|
AccessToken: 'test-emby-token-abc123',
|
||||||
|
User: { Id: 'user-id-001', Name: 'TestUser' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMBY_USER_BODY = {
|
||||||
|
Id: 'user-id-001',
|
||||||
|
Name: 'TestUser',
|
||||||
|
Policy: { IsAdministrator: false }
|
||||||
|
};
|
||||||
|
|
||||||
// Minimal valid Sonarr Grab payload
|
// Minimal valid Sonarr Grab payload
|
||||||
const SONARR_GRAB = {
|
const SONARR_GRAB = {
|
||||||
@@ -53,7 +65,31 @@ const SONARR_TEST = {
|
|||||||
date: '2026-05-19T10:00:02.000Z'
|
date: '2026-05-19T10:00:02.000Z'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, EMBY_AUTH_BODY);
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get(/\/Users\//)
|
||||||
|
.reply(200, userBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
|
||||||
|
const userBody = isAdmin ? { ...EMBY_USER_BODY, Policy: { IsAdministrator: true } } : EMBY_USER_BODY;
|
||||||
|
interceptSuccessfulLogin(userBody);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username, password: 'password' });
|
||||||
|
|
||||||
|
const cookies = res.headers['set-cookie'];
|
||||||
|
const csrfToken = res.body.csrfToken;
|
||||||
|
|
||||||
|
return { cookies, csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
function makeApp() {
|
function makeApp() {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||||
@@ -61,6 +97,9 @@ function makeApp() {
|
|||||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||||
]);
|
]);
|
||||||
|
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
||||||
|
]);
|
||||||
return createApp({ skipRateLimits: true });
|
return createApp({ skipRateLimits: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +116,20 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
|
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
nock.cleanAll();
|
nock.cleanAll();
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
delete process.env.SOFARR_BASE_URL;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.OMBI_INSTANCES;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -393,3 +438,241 @@ describe('Security — secret never leaks', () => {
|
|||||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/webhook/config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('GET /api/webhook/config', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/webhook/config')
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(res.body.error).toBe('Not authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid: true when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured', async () => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/webhook/config')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
expect(res.body.missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid: false when SOFARR_BASE_URL is missing', async () => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
delete process.env.SOFARR_BASE_URL;
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/webhook/config')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.valid).toBe(false);
|
||||||
|
expect(res.body.missing).toEqual(['SOFARR_BASE_URL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid: false when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/webhook/config')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.valid).toBe(false);
|
||||||
|
expect(res.body.missing).toEqual(['SOFARR_WEBHOOK_SECRET']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid: false when both are missing', async () => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
delete process.env.SOFARR_BASE_URL;
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/webhook/config')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.valid).toBe(false);
|
||||||
|
expect(res.body.missing).toContain('SOFARR_BASE_URL');
|
||||||
|
expect(res.body.missing).toContain('SOFARR_WEBHOOK_SECRET');
|
||||||
|
expect(res.body.missing).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ombi webhook receiver
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('POST /api/webhook/ombi', () => {
|
||||||
|
function postOmbi(app, payload, secret = VALID_SECRET) {
|
||||||
|
const req = request(app).post('/api/webhook/ombi').send(payload);
|
||||||
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { requestId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when notificationType is unknown', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
// Nock requests endpoint since processWebhookEvent will fetch requests
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 123,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:30:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid RequestAvailable event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 124,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'Available Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Available',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:31:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns duplicate: true for a replay of the same event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 125,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:32:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const res1 = await postOmbi(app, payload);
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.duplicate).toBeUndefined();
|
||||||
|
|
||||||
|
// Replay
|
||||||
|
const res2 = await postOmbi(app, payload);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.duplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
NotificationType: 'NewRequest',
|
||||||
|
RequestId: 126,
|
||||||
|
RequestedUser: { UserName: 'gordon_pascal' },
|
||||||
|
Title: 'Pascal Movie',
|
||||||
|
Type: 'Movie',
|
||||||
|
RequestStatus: 'Pending',
|
||||||
|
ApplicationUrl: 'https://ombi.test',
|
||||||
|
RequestedDate: '2026-05-23T20:33:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||