Compare commits

..

102 Commits

Author SHA1 Message Date
gronod b40307a421 merge branch 'develop' into 'main' - Release v1.7.36
CI / Swagger Validation & Coverage (push) Successful in 1m48s
Create Release / release (push) Successful in 22s
CI / Security audit (push) Successful in 4m18s
Build and Push Docker Image / build (push) Successful in 1m57s
CI / Tests & coverage (push) Successful in 8m50s
2026-05-29 13:38:02 +01:00
gronod 6c4aedf60e chore: bump version to 1.7.36 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m57s
Docs Check / Markdown lint (push) Failing after 2m21s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m11s
CI / Security audit (push) Successful in 3m44s
CI / Swagger Validation & Coverage (push) Successful in 4m4s
Docs Check / Mermaid diagram parse check (push) Successful in 4m37s
CI / Tests & coverage (push) Successful in 8m29s
2026-05-29 13:37:56 +01:00
gronod 97e2f256e6 merge branch 'develop' into 'main' - Release v1.7.35
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Create Release / release (push) Successful in 27s
CI / Security audit (push) Successful in 4m0s
Build and Push Docker Image / build (push) Successful in 1m33s
CI / Tests & coverage (push) Failing after 5m4s
2026-05-29 13:24:33 +01:00
gronod 53eb19ba0c chore: bump version to 1.7.35 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m35s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m12s
CI / Security audit (push) Successful in 3m43s
Docs Check / Mermaid diagram parse check (push) Successful in 4m12s
CI / Swagger Validation & Coverage (push) Successful in 4m31s
CI / Tests & coverage (push) Successful in 5m15s
2026-05-29 13:24:27 +01:00
gronod 2f32edf77f fix: restrict arrInstanceKey to admin users in buildArrDownload (#73)
Build and Push Docker Image / build (push) Successful in 1m2s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m56s
CI / Security audit (push) Successful in 2m19s
CI / Swagger Validation & Coverage (push) Successful in 2m29s
CI / Tests & coverage (push) Successful in 3m7s
2026-05-29 13:05:21 +01:00
gronod 0364a3c824 refactor: add JSDoc examples and log helper tracing in titleMatches (#73)
Build and Push Docker Image / build (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m8s
CI / Security audit (push) Successful in 2m34s
CI / Swagger Validation & Coverage (push) Successful in 2m47s
CI / Tests & coverage (push) Failing after 2m51s
2026-05-29 12:57:01 +01:00
gronod 50e1e09e55 fix: support orphaned *arr queue items and improve download matching reliability (#73)
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m31s
Docs Check / Mermaid diagram parse check (push) Successful in 3m48s
CI / Tests & coverage (push) Failing after 4m7s
2026-05-29 12:46:11 +01:00
gronod bbc461ad6e Fix #67: Clarify proxy routes expose selective subset of upstream APIs
- Update README Proxy Routes section to explicitly state only a selective subset of endpoints is exposed
- Replace wildcard API endpoint listings with complete list of 30 specific implemented proxy endpoints
- Update OpenAPI tag descriptions for Sonarr/Radarr to clarify selective proxy scope
2026-05-28 18:56:53 +01:00
gronod d29b6e9223 merge branch 'develop' into 'main' - Release v1.7.34
Create Release / release (push) Successful in 38s
Build and Push Docker Image / build (push) Successful in 2m38s
2026-05-28 18:15:33 +01:00
gronod df5328349b chore: bump version to 1.7.34 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m46s
Docs Check / Markdown lint (push) Successful in 2m19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m53s
CI / Security audit (push) Successful in 3m23s
Docs Check / Mermaid diagram parse check (push) Successful in 3m55s
CI / Swagger Validation & Coverage (push) Successful in 4m18s
CI / Tests & coverage (push) Successful in 4m36s
2026-05-28 18:15:27 +01:00
gronod b9c8c0be87 style(ui): unify tab headers layout, typography, and icons (closes #72)
Build and Push Docker Image / build (push) Successful in 1m28s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
2026-05-28 18:12:53 +01:00
gronod 06818dbf29 fix(webhooks): skip replay protection for Test events (closes #71)
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m56s
2026-05-28 18:11:45 +01:00
gronod 7f7a91f056 merge branch 'develop' into 'main' - Release v1.7.33
CI / Tests & coverage (push) Successful in 2m53s
Create Release / release (push) Successful in 17s
CI / Security audit (push) Successful in 3m8s
Build and Push Docker Image / build (push) Successful in 1m11s
CI / Swagger Validation & Coverage (push) Successful in 2m23s
2026-05-28 17:42:54 +01:00
gronod 1dc8d8a26c chore: bump version to 1.7.33 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Successful in 2m4s
Build and Push Docker Image / build (push) Successful in 2m16s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m31s
CI / Security audit (push) Successful in 2m57s
Docs Check / Mermaid diagram parse check (push) Successful in 3m35s
CI / Swagger Validation & Coverage (push) Successful in 4m5s
CI / Tests & coverage (push) Successful in 4m6s
2026-05-28 17:42:43 +01:00
gronod af33e4ec43 feat(ui): align requests container and cards layout with downloads/history (closes #69) 2026-05-28 17:41:59 +01:00
gronod a4d398ef1b fix(webhooks): correct Ombi isReplay() call after signature change (closes #70) 2026-05-28 17:40:33 +01:00
gronod 879aee8eea merge branch 'develop' into 'main' - Release v1.7.32 (include markdownlint fix)
Create Release / release (push) Successful in 30s
Build and Push Docker Image / build (push) Successful in 1m24s
CI / Security audit (push) Successful in 2m1s
CI / Swagger Validation & Coverage (push) Successful in 2m16s
CI / Tests & coverage (push) Successful in 2m37s
2026-05-28 16:28:23 +01:00
gronod 70710061b8 Fix markdownlint MD037 error in CHANGELOG.md
Build and Push Docker Image / build (push) Successful in 1m45s
Docs Check / Markdown lint (push) Successful in 38s
CI / Security audit (push) Successful in 2m15s
CI / Tests & coverage (push) Successful in 2m47s
CI / Swagger Validation & Coverage (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m1s
2026-05-28 16:26:17 +01:00
gronod f8f693e32a merge branch 'develop' into 'main' - Release v1.7.32
Create Release / release (push) Successful in 24s
CI / Security audit (push) Successful in 2m48s
Build and Push Docker Image / build (push) Successful in 2m14s
CI / Swagger Validation & Coverage (push) Successful in 2m54s
CI / Tests & coverage (push) Has been cancelled
2026-05-28 16:25:07 +01:00
gronod 501a4c83bb chore: bump version to 1.7.32 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m26s
Docs Check / Markdown lint (push) Failing after 1m12s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m23s
Docs Check / Mermaid diagram parse check (push) Successful in 2m58s
2026-05-28 16:24:24 +01:00
gronod 6fa9c79a7d fix: rTorrent null-safety, configurable SAB_HISTORY_LIMIT, lastError visibility (#68)
Build and Push Docker Image / build (push) Successful in 59s
Docs Check / Markdown lint (push) Failing after 1m45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m7s
CI / Security audit (push) Successful in 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 2m55s
CI / Swagger Validation & Coverage (push) Successful in 3m19s
CI / Tests & coverage (push) Successful in 3m29s
- RTorrentClient: guard d.multicall2 returning non-array, per-row try/catch,
  explicit Number()/String() coercions, _extractArrInfo null-safe
- RTorrentClient.getClientStatus: coerce rates through Number.isFinite
- SABnzbdClient: history limit now reads SAB_HISTORY_LIMIT env var (default 10)
- DownloadClient: added _recordLastError, _clearLastError, getLastError on base
- All four clients call _recordLastError on failure, _clearLastError on success
- DownloadClientRegistry.getAllClientStatuses: includes lastError in result
- GET /api/status/status: exposes downloadClients[] array with per-client lastError
- Tests: RTorrentClient null-safety + lastError, SABnzbd history limit + lastError,
  downloadClients.test expectation updated for new lastError field
2026-05-28 16:22:11 +01:00
gronod 3d49c926dc fix(transmission): map status 7 to Checking, implement control methods (closes #63)
Docs Check / Markdown lint (push) Failing after 1m14s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m54s
CI / Security audit (push) Successful in 2m16s
CI / Swagger Validation & Coverage (push) Successful in 2m42s
Docs Check / Mermaid diagram parse check (push) Successful in 2m43s
CI / Tests & coverage (push) Successful in 2m55s
2026-05-28 16:01:33 +01:00
gronod bd7a9c7951 fix(frontend): document non-standard vite config, clean stale client/dist (closes #66)
Build and Push Docker Image / build (push) Successful in 1m20s
Docs Check / Markdown lint (push) Failing after 1m39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m56s
CI / Security audit (push) Successful in 2m30s
CI / Tests & coverage (push) Successful in 2m45s
CI / Swagger Validation & Coverage (push) Successful in 2m52s
Docs Check / Mermaid diagram parse check (push) Successful in 2m47s
2026-05-28 15:59:05 +01:00
gronod 4a5dc70548 fix(matching): add torrent hash/downloadId matching, deduplicate by arrQueueId (closes #65)
Docs Check / Markdown lint (push) Failing after 46s
Build and Push Docker Image / build (push) Successful in 1m4s
CI / Security audit (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m8s
2026-05-28 15:57:35 +01:00
gronod 498eabc7bc fix(qbittorrent): add seeds/peers fields (num_seeds/num_leechs), guard empty response (closes #64)
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
Docs Check / Markdown lint (push) Successful in 1m19s
CI / Security audit (push) Successful in 2m8s
Docs Check / Mermaid diagram parse check (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 2m40s
CI / Tests & coverage (push) Successful in 2m54s
2026-05-28 15:52:52 +01:00
gronod 6b73727d4e fix(queue): extract shared arr cache helper, annotate season packs, null-guard flatMap (closes #61) 2026-05-28 15:36:33 +01:00
gronod 593ad79670 fix(webhooks): redesign replay key with content identifiers, log instance fallback (closes #62) 2026-05-28 15:30:08 +01:00
gronod c18f5bd26e merge branch 'develop' into 'main' - Release v1.7.31
CI / Tests & coverage (push) Successful in 2m53s
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m18s
CI / Swagger Validation & Coverage (push) Successful in 1m40s
2026-05-28 08:12:26 +01:00
gronod b4a9d7187b chore: add build script helper, fix client index entrypoint, and copy full public build folder in Dockerfile
Build and Push Docker Image / build (push) Successful in 2m16s
Docs Check / Markdown lint (push) Successful in 2m23s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m39s
CI / Security audit (push) Successful in 3m14s
CI / Swagger Validation & Coverage (push) Successful in 3m32s
Docs Check / Mermaid diagram parse check (push) Successful in 3m39s
CI / Tests & coverage (push) Successful in 3m50s
2026-05-28 08:12:10 +01:00
gronod 691d101e56 chore: bump version to 1.7.31 and update CHANGELOG and docs 2026-05-28 08:11:12 +01:00
gronod e726fbe33f merge branch 'develop' into 'main' - Release v1.7.30
Create Release / release (push) Successful in 42s
CI / Security audit (push) Successful in 1m36s
CI / Swagger Validation & Coverage (push) Successful in 3m20s
CI / Tests & coverage (push) Successful in 3m53s
2026-05-28 08:03:41 +01:00
gronod 6f2901b08c fix: resolve frontend connection issues by introducing concurrent startup and dynamic proxy configuration
Build and Push Docker Image / build (push) Successful in 2m6s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m23s
CI / Security audit (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
CI / Tests & coverage (push) Failing after 3m51s
2026-05-28 08:03:29 +01:00
gronod 4107bdf611 merge branch 'develop' into 'main' - Fix CHANGELOG formatting for Release v1.7.30
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m55s
CI / Swagger Validation & Coverage (push) Successful in 1m24s
Create Release / release (push) Successful in 14s
2026-05-28 07:02:57 +01:00
gronod a4af16064b docs: fix markdownlint formatting error on CHANGELOG.md line 40
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m39s
CI / Swagger Validation & Coverage (push) Successful in 1m15s
Docs Check / Markdown lint (push) Successful in 35s
Docs Check / Mermaid diagram parse check (push) Successful in 1m24s
2026-05-28 07:02:51 +01:00
gronod 52806d00dc merge branch 'develop' into 'main' - Release v1.7.30
CI / Tests & coverage (push) Successful in 2m9s
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m52s
CI / Swagger Validation & Coverage (push) Successful in 2m4s
2026-05-28 01:39:13 +01:00
gronod d6907f42d3 chore: bump version to 1.7.30 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Failing after 1m32s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m59s
CI / Security audit (push) Successful in 2m30s
Docs Check / Mermaid diagram parse check (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m18s
CI / Tests & coverage (push) Successful in 1m20s
2026-05-28 01:39:01 +01:00
gronod aec04474be tests: expand coverage for poller, rate limiter, ombi decoration, downloads UI, and SSE streaming lifecycle (closes #60)
- Add tests/unit/utils/poller.test.js covering background polling lock, registry, error recovery, webhook bypasses, and global fallbacks
- Add tests/integration/rateLimiter.test.js verifying 429 response rate-limiting in an isolated production environment
- Add tests/integration/ombiDecoration.test.js covering deep links and admin role checks
- Expand tests/frontend/ui/downloads.test.js covering createServiceIcons() and createClientLogo() fallbacks
- Expand tests/integration/dashboard.test.js verifying SSE heartbeats, payload schema contract, and listener cleanup on client disconnect
2026-05-28 01:38:30 +01:00
gronod dcb77dd27f merge branch 'develop' into 'main' - Release v1.7.29
CI / Security audit (push) Successful in 2m47s
Create Release / release (push) Successful in 40s
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 3m32s
2026-05-27 23:51:12 +01:00
gronod f5315e5ceb chore: bump version to 1.7.29 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m39s
Docs Check / Markdown lint (push) Failing after 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m11s
CI / Swagger Validation & Coverage (push) Successful in 2m51s
CI / Security audit (push) Successful in 3m9s
Docs Check / Mermaid diagram parse check (push) Successful in 3m21s
CI / Tests & coverage (push) Failing after 3m44s
2026-05-27 23:46:40 +01:00
gronod 13f3d767c5 fix: resolve missing Radarr and Sonarr links on active downloads (fixes #59) 2026-05-27 23:46:35 +01:00
gronod 6c3ffb9b77 merge branch 'develop' into 'main' - Release v1.7.28
Create Release / release (push) Successful in 22s
CI / Swagger Validation & Coverage (push) Successful in 1m52s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m1s
CI / Tests & coverage (push) Successful in 3m38s
2026-05-27 23:26:11 +01:00
gronod a37874c553 chore: bump version to 1.7.28 and update CHANGELOG and docs
CI / Security audit (push) Successful in 1m27s
Build and Push Docker Image / build (push) Successful in 2m0s
Docs Check / Markdown lint (push) Failing after 2m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
Docs Check / Mermaid diagram parse check (push) Successful in 3m24s
CI / Tests & coverage (push) Successful in 3m34s
2026-05-27 23:25:57 +01:00
gronod 5933e09652 fix: resolve missing Sonarr link button on TV request cards (fixes #58) 2026-05-27 23:25:53 +01:00
gronod 7226404221 merge branch 'develop' into 'main' - Release v1.7.27
Create Release / release (push) Successful in 37s
CI / Swagger Validation & Coverage (push) Successful in 2m5s
CI / Security audit (push) Successful in 2m59s
Build and Push Docker Image / build (push) Successful in 1m39s
CI / Tests & coverage (push) Successful in 3m48s
2026-05-27 23:11:37 +01:00
gronod 1ee2a8044b chore: bump version to 1.7.27 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m53s
Docs Check / Mermaid diagram parse check (push) Successful in 2m46s
CI / Security audit (push) Successful in 3m14s
CI / Swagger Validation & Coverage (push) Successful in 3m32s
CI / Tests & coverage (push) Successful in 3m55s
2026-05-27 23:11:23 +01:00
gronod 86277e2059 fix: serve frontend static files and handle SPA routes (fixes #57) 2026-05-27 23:11:19 +01:00
gronod 0eaa54cf4a merge branch 'develop' into 'main' - Release v1.7.26
Create Release / release (push) Successful in 8s
CI / Security audit (push) Successful in 3m0s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
Build and Push Docker Image / build (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 3m37s
2026-05-27 22:50:31 +01:00
gronod 865cf1f57a chore: bump version to 1.7.26 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m12s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
Docs Check / Mermaid diagram parse check (push) Successful in 3m18s
CI / Tests & coverage (push) Successful in 3m28s
2026-05-27 22:50:29 +01:00
gronod ff5f50cc3a chore: remove reg.i3omb.com from build-image workflow
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
CI / Tests & coverage (push) Successful in 2m26s
Only publish container images to git.i3omb.com registry.
2026-05-27 21:46:34 +01:00
gronod fd0dc7528d merge branch 'develop' into 'main' - Release v1.7.25
Create Release / release (push) Successful in 23s
CI / Security audit (push) Successful in 1m49s
Build and Push Docker Image / build (push) Successful in 1m50s
CI / Swagger Validation & Coverage (push) Successful in 2m18s
CI / Tests & coverage (push) Successful in 2m30s
2026-05-27 21:43:45 +01:00
gronod 33b122d22b fix(ombi): resolve TV request status, user, and date display (Issue #53)
Build and Push Docker Image / build (push) Successful in 1m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m33s
CI / Security audit (push) Successful in 1m56s
CI / Swagger Validation & Coverage (push) Successful in 2m35s
CI / Tests & coverage (push) Successful in 2m51s
Ombi's TV API nests all request data (requestedUser, approved, available,
denied, requested, requestedDate) inside childRequests[] sub-objects.
The application previously only inspected top-level properties, causing
TV shows to consistently display 'unknown' status, 'unknown' user, and
no request date.

Changes:
- OmbiRetriever._hydrateRequest(): hydrate requestedUser on each
  childRequests entry and promote requestedDate to top level
- getRequestStatus() (server + client): aggregate status flags from
  childRequests[] when top-level properties are absent
- Client date display: fallback to childRequests[0].requestedDate
- Add 18 unit tests covering childRequests hydration, status
  aggregation, and date promotion

Closes #53
2026-05-27 21:13:17 +01:00
gronod c4e584cc3b merge branch 'develop' into 'main' - Release v1.7.24
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 57s
CI / Security audit (push) Successful in 3m7s
CI / Swagger Validation & Coverage (push) Successful in 1m49s
CI / Tests & coverage (push) Successful in 3m43s
2026-05-27 19:35:19 +01:00
gronod 35ff21a810 chore: bump version to 1.7.24 and update CHANGELOG and workflows
Build and Push Docker Image / build (push) Successful in 1m48s
Docs Check / Markdown lint (push) Successful in 1m36s
CI / Swagger Validation & Coverage (push) Successful in 2m9s
CI / Security audit (push) Successful in 2m10s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m23s
Docs Check / Mermaid diagram parse check (push) Successful in 3m2s
CI / Tests & coverage (push) Successful in 3m34s
2026-05-27 19:35:06 +01:00
gronod 610632c4f0 merge branch 'develop' into 'main' - Release v1.7.23
Build and Push Docker Image / build (push) Successful in 1m4s
Create Release / release (push) Successful in 53s
CI / Security audit (push) Successful in 1m42s
CI / Swagger Validation & Coverage (push) Successful in 1m42s
CI / Tests & coverage (push) Successful in 2m21s
2026-05-27 19:16:23 +01:00
gronod 5b3034e290 chore: bump version to 1.7.23 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Successful in 46s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m22s
CI / Security audit (push) Successful in 2m48s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
Docs Check / Mermaid diagram parse check (push) Successful in 3m9s
CI / Tests & coverage (push) Successful in 3m36s
2026-05-27 19:16:12 +01:00
gronod 1535a5725a merge branch 'develop' into 'main' - Release v1.7.22
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
CI / Security audit (push) Successful in 2m26s
CI / Tests & coverage (push) Successful in 2m55s
2026-05-27 17:44:51 +01:00
gronod 95bd703b26 chore: bump version to 1.7.22 and update CHANGELOG, tests and docs
Docs Check / Markdown lint (push) Successful in 1m14s
Build and Push Docker Image / build (push) Successful in 1m52s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m25s
Docs Check / Mermaid diagram parse check (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
CI / Tests & coverage (push) Successful in 3m45s
2026-05-27 17:42:41 +01:00
gronod 8fb00843ef merge branch 'develop' into 'main' - Release v1.7.21
CI / Security audit (push) Successful in 2m50s
CI / Swagger Validation & Coverage (push) Successful in 3m23s
CI / Tests & coverage (push) Successful in 3m30s
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 11s
2026-05-26 15:20:40 +01:00
gronod d2ac7731ca chore: bump version to 1.7.21 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m21s
CI / Security audit (push) Successful in 1m26s
Docs Check / Markdown lint (push) Successful in 1m27s
CI / Swagger Validation & Coverage (push) Successful in 2m25s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m30s
Docs Check / Mermaid diagram parse check (push) Successful in 3m17s
CI / Tests & coverage (push) Successful in 3m31s
2026-05-26 15:20:12 +01:00
gronod 6f6aa5b967 merge branch 'develop' into 'main' - Release v1.7.20
Build and Push Docker Image / build (push) Successful in 1m13s
Create Release / release (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m36s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
CI / Tests & coverage (push) Successful in 3m15s
2026-05-26 13:43:33 +01:00
gronod 5390bbf615 chore: bump version to 1.7.20 and resolve Ombi user hydration issue
Build and Push Docker Image / build (push) Successful in 2m6s
Docs Check / Markdown lint (push) Successful in 1m58s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m4s
Docs Check / Mermaid diagram parse check (push) Successful in 1m58s
CI / Security audit (push) Successful in 1m48s
CI / Tests & coverage (push) Successful in 1m59s
CI / Swagger Validation & Coverage (push) Successful in 1m47s
2026-05-26 11:30:49 +01:00
gronod fb68bddedb merge branch 'develop' into 'main' - Update Release v1.7.19 to ignore scratch directory
Build and Push Docker Image / build (push) Successful in 1m8s
Create Release / release (push) Successful in 36s
CI / Tests & coverage (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 1m50s
2026-05-25 08:33:16 +01:00
gronod 81d0aa82f2 chore: add scratch/ to gitignore and untrack existing scratch files
Build and Push Docker Image / build (push) Successful in 1m17s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m26s
CI / Swagger Validation & Coverage (push) Successful in 3m1s
CI / Security audit (push) Successful in 3m15s
CI / Tests & coverage (push) Successful in 3m42s
2026-05-25 08:32:33 +01:00
gronod 7d7304637c merge branch 'develop' into 'main' - Release v1.7.19
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 2m19s
CI / Security audit (push) Successful in 2m14s
CI / Tests & coverage (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m24s
2026-05-25 08:28:28 +01:00
gronod d87ad9f1c7 fix: mobile request card overflow (#49) and admin arrLink active badges (#50), bump version to 1.7.19
Build and Push Docker Image / build (push) Successful in 1m23s
Docs Check / Markdown lint (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 3m19s
CI / Security audit (push) Successful in 3m21s
CI / Swagger Validation & Coverage (push) Successful in 3m40s
CI / Tests & coverage (push) Successful in 4m57s
2026-05-25 08:28:16 +01:00
gronod ce71be29c4 merge branch 'develop' into 'main' - Release v1.7.18
Create Release / release (push) Successful in 1m35s
Build and Push Docker Image / build (push) Successful in 1m42s
CI / Swagger Validation & Coverage (push) Successful in 2m58s
CI / Tests & coverage (push) Successful in 3m13s
CI / Security audit (push) Successful in 1m41s
2026-05-24 23:26:45 +01:00
gronod b8870ca6cf chore: bump version to 1.7.18 and update CHANGELOG and docs
CI / Tests & coverage (push) Failing after 33s
Docs Check / Markdown lint (push) Successful in 1m57s
Docs Check / Mermaid diagram parse check (push) Successful in 2m59s
CI / Security audit (push) Successful in 3m32s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m54s
Build and Push Docker Image / build (push) Successful in 1m32s
CI / Swagger Validation & Coverage (push) Successful in 2m34s
2026-05-24 23:26:27 +01:00
gronod 90837f6e3b fix: mobile overflow on Requests tab cards
- Remove white-space: nowrap from .request-title so long titles
  truncate/wrap instead of forcing cards beyond the viewport width
- Add overflow-x: hidden to .requests-list as a safety net
- Add @media (max-width: 768px) rules for the requests section:
  reduce .requests-container padding from 20px to 12px, tighten
  .request-card and .request-meta gaps on mobile

Fixes #49
2026-05-24 23:18:59 +01:00
gronod fbc071da09 merge branch 'develop' into 'main' - Release v1.7.17
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 48s
CI / Swagger Validation & Coverage (push) Successful in 2m45s
CI / Security audit (push) Successful in 2m9s
CI / Tests & coverage (push) Failing after 32s
2026-05-24 22:49:16 +01:00
gronod 7690d959b3 fix: blocklist-search lookup against queue cache instead of downloadClientRegistry
CI / Security audit (push) Successful in 1m52s
Docs Check / Markdown lint (push) Successful in 1m37s
Build and Push Docker Image / build (push) Successful in 2m2s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
Docs Check / Mermaid diagram parse check (push) Successful in 3m31s
CI / Tests & coverage (push) Successful in 4m5s
Fixes the root cause of the regression from v1.7.16. The v1.7.16 fix
correctly cast arrQueueId to String, but the lookup was performed
against downloadClientRegistry.getAllDownloads() which returns raw
download client data (qBittorrent, SABnzbd, etc.) that never has
arrQueueId populated.

The fix now looks up the queue record directly from the Sonarr/Radarr
queue cache where record.id is the numeric queue ID, using String()
casting on both sides to handle the DOM-dataset (string) vs API
response (number) type difference.

Resolves Gitea Issue #48
Closes #48
2026-05-24 22:48:17 +01:00
gronod 1ba9d15954 merge branch 'develop' into 'main' - Release v1.7.16
Create Release / release (push) Successful in 21s
Build and Push Docker Image / build (push) Successful in 2m11s
CI / Security audit (push) Successful in 1m57s
CI / Tests & coverage (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
2026-05-24 22:13:28 +01:00
gronod 83c9d4d164 fix: blocklist-search queue ID type mismatch and bump version to 1.7.16
Build and Push Docker Image / build (push) Successful in 2m14s
Docs Check / Markdown lint (push) Successful in 2m29s
CI / Security audit (push) Successful in 2m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m52s
Docs Check / Mermaid diagram parse check (push) Successful in 4m8s
CI / Tests & coverage (push) Successful in 4m38s
- Cast arrQueueId to String in both sides of the download lookup comparison
  in /api/dashboard/blocklist-search to resolve false-negative match failure
  caused by DOM dataset string vs Radarr/Sonarr API number type mismatch
- Add regression integration test for string-vs-number arrQueueId matching
- Bump version to 1.7.16, update CHANGELOG.md, openapi.yaml, and JSDoc examples

Resolves #48
2026-05-24 22:12:34 +01:00
gronod f2c01903fa fix: resolve Mermaid syntax parse error in ARCHITECTURE.md
Create Release / release (push) Successful in 44s
Docs Check / Markdown lint (push) Successful in 55s
Build and Push Docker Image / build (push) Successful in 57s
Docs Check / Mermaid diagram parse check (push) Successful in 2m39s
CI / Tests & coverage (push) Successful in 2m3s
CI / Security audit (push) Successful in 2m47s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
2026-05-24 21:30:51 +01:00
gronod 8b6ef0f64f merge branch 'develop' into 'main' - Release v1.7.15
Build and Push Docker Image / build (push) Successful in 1m43s
Create Release / release (push) Successful in 39s
CI / Security audit (push) Successful in 1m49s
CI / Swagger Validation & Coverage (push) Successful in 2m0s
CI / Tests & coverage (push) Successful in 2m51s
2026-05-24 21:25:55 +01:00
gronod 7b9c895888 fix: support query parameter-based secret validation fallback to fix Ombi webhooks (#47)
Build and Push Docker Image / build (push) Successful in 2m4s
Docs Check / Markdown lint (push) Successful in 2m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m3s
CI / Security audit (push) Successful in 3m42s
Docs Check / Mermaid diagram parse check (push) Failing after 3m58s
CI / Tests & coverage (push) Successful in 4m21s
CI / Swagger Validation & Coverage (push) Successful in 4m33s
2026-05-24 21:25:38 +01:00
gronod 2b5ac2d7c5 merge branch 'develop' into 'main' - Release v1.7.14
Build and Push Docker Image / build (push) Successful in 1m48s
Create Release / release (push) Successful in 32s
CI / Security audit (push) Successful in 2m16s
CI / Tests & coverage (push) Successful in 2m46s
CI / Swagger Validation & Coverage (push) Successful in 2m36s
2026-05-24 19:37:03 +01:00
gronod b5b4862e15 chore: bump version to 1.7.14 and update CHANGELOG for poller fix
Build and Push Docker Image / build (push) Successful in 1m42s
Docs Check / Markdown lint (push) Successful in 1m34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m37s
CI / Security audit (push) Successful in 3m7s
Docs Check / Mermaid diagram parse check (push) Failing after 3m52s
CI / Swagger Validation & Coverage (push) Successful in 4m11s
CI / Tests & coverage (push) Successful in 4m41s
2026-05-24 19:36:53 +01:00
gronod 11b3296198 merge branch 'develop' into 'main' - Release v1.7.13
Build and Push Docker Image / build (push) Successful in 1m58s
Create Release / release (push) Successful in 49s
CI / Security audit (push) Successful in 2m16s
CI / Tests & coverage (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 2m56s
2026-05-24 19:24:16 +01:00
gronod 76631cd37e chore: bump version to 1.7.13 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m2s
Docs Check / Markdown lint (push) Successful in 1m54s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m42s
CI / Tests & coverage (push) Successful in 3m7s
CI / Security audit (push) Successful in 3m35s
Docs Check / Mermaid diagram parse check (push) Failing after 3m53s
CI / Swagger Validation & Coverage (push) Successful in 4m26s
2026-05-24 19:24:01 +01:00
gronod 88bfa52eba merge branch 'develop' into 'main' - Release v1.7.12
Build and Push Docker Image / build (push) Successful in 2m1s
Create Release / release (push) Successful in 46s
CI / Security audit (push) Successful in 1m48s
CI / Swagger Validation & Coverage (push) Successful in 1m59s
CI / Tests & coverage (push) Successful in 2m36s
2026-05-24 18:50:41 +01:00
gronod 95e301ef56 chore: bump version to 1.7.12 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 1m37s
Docs Check / Markdown lint (push) Successful in 2m1s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m37s
CI / Security audit (push) Successful in 3m0s
Docs Check / Mermaid diagram parse check (push) Failing after 3m18s
CI / Swagger Validation & Coverage (push) Successful in 3m30s
CI / Tests & coverage (push) Successful in 3m55s
2026-05-24 18:50:30 +01:00
gronod 3c6791658c feat: implement togglable debug log streaming for server stdout/stderr and client console logs
- Created server/utils/logCapture.js to intercept and buffer server output, stripping ANSI escape codes.
- Created server/middleware/logStreamAuth.js enforcing subnet IP filtering (LOG_ALLOW_SUBNETS), Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass.
- Created server/routes/debug.js with SSE streams /api/debug/server-logs, /api/debug/client-logs and batched POST /api/debug/client-logs. Exposes public configuration status at /api/debug/status.
- Integrated log capture and mounted debug routes in server/app.js and server/index.js.
- Implemented client/src/utils/clientLogCapture.js in the frontend SPA to hook console log/warn/error and flush batched console events.
- Documented all endpoints in OpenAPI server/openapi.yaml, ARCHITECTURE.md, and README.md.
- Wrote route integration tests and frontend console capture tests, with full validation in swagger-coverage.
2026-05-24 11:31:36 +01:00
gronod 9c7dcf55b0 merge branch 'develop' into 'main' - Release v1.7.11
Build and Push Docker Image / build (push) Successful in 56s
Create Release / release (push) Successful in 48s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
CI / Security audit (push) Successful in 2m31s
CI / Tests & coverage (push) Successful in 3m24s
2026-05-24 10:49:01 +01:00
gronod afc940aba7 chore: bump version to 1.7.11 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m2s
Docs Check / Markdown lint (push) Successful in 1m10s
Docs Check / Mermaid diagram parse check (push) Successful in 1m39s
CI / Security audit (push) Successful in 3m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m31s
CI / Swagger Validation & Coverage (push) Successful in 4m24s
CI / Tests & coverage (push) Successful in 4m39s
2026-05-24 10:48:52 +01:00
gronod 5488969387 fix: resolve blocklist & search failures on Sonarr season packs and multi-episode releases
Build and Push Docker Image / build (push) Successful in 1m31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m42s
CI / Security audit (push) Successful in 3m26s
CI / Swagger Validation & Coverage (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 4m19s
2026-05-24 10:42:54 +01:00
gronod ec9b1c6d94 merge branch 'develop' into 'main' - Release v1.7.10
Create Release / release (push) Successful in 38s
Build and Push Docker Image / build (push) Successful in 1m29s
CI / Security audit (push) Successful in 2m12s
CI / Tests & coverage (push) Successful in 2m48s
CI / Swagger Validation & Coverage (push) Successful in 2m37s
2026-05-24 10:23:30 +01:00
gronod 3f8970ea99 chore: bump version to 1.7.10 and update CHANGELOG
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 50s
Build and Push Docker Image / build (push) Successful in 2m21s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m48s
Docs Check / Mermaid diagram parse check (push) Successful in 2m46s
2026-05-24 10:23:22 +01:00
gronod 9491948ec9 fix: resolve Ombi webhook race condition and add Ombi webhook status metrics to status panel
CI / Security audit (push) Successful in 1m45s
Build and Push Docker Image / build (push) Successful in 1m43s
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
2026-05-24 10:22:47 +01:00
gronod d4ee3b8ef7 merge branch 'develop' into 'main' - Release v1.7.9
Create Release / release (push) Successful in 19s
Build and Push Docker Image / build (push) Successful in 1m51s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 2m41s
CI / Tests & coverage (push) Successful in 3m20s
2026-05-23 20:58:20 +01:00
gronod 64c872423f chore: bump version to 1.7.9 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m12s
Docs Check / Markdown lint (push) Successful in 2m34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m14s
CI / Security audit (push) Successful in 3m29s
Docs Check / Mermaid diagram parse check (push) Successful in 3m47s
CI / Swagger Validation & Coverage (push) Successful in 4m12s
CI / Tests & coverage (push) Successful in 4m57s
2026-05-23 20:58:09 +01:00
gronod 86c67bcf29 fix: support PascalCase properties in Ombi webhooks (#42) 2026-05-23 20:57:55 +01:00
gronod 9548eb41f5 merge branch 'develop' into 'main' - Release v1.7.8
Create Release / release (push) Successful in 30s
CI / Security audit (push) Successful in 2m9s
Build and Push Docker Image / build (push) Successful in 2m18s
CI / Swagger Validation & Coverage (push) Successful in 2m36s
CI / Tests & coverage (push) Successful in 3m15s
2026-05-23 20:52:46 +01:00
gronod d1db3118f0 chore: bump version to 1.7.8 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m9s
CI / Security audit (push) Successful in 2m32s
Docs Check / Markdown lint (push) Successful in 2m34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m55s
Docs Check / Mermaid diagram parse check (push) Successful in 4m18s
CI / Tests & coverage (push) Successful in 4m34s
2026-05-23 20:52:27 +01:00
gronod f8aa90011e merge branch 'develop' into 'main' - Release v1.7.7
Create Release / release (push) Successful in 22s
Build and Push Docker Image / build (push) Successful in 2m3s
CI / Security audit (push) Successful in 1m56s
CI / Tests & coverage (push) Successful in 2m28s
CI / Swagger Validation & Coverage (push) Successful in 2m21s
2026-05-23 20:38:14 +01:00
gronod 82b3824658 chore: bump version to 1.7.7 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m17s
Docs Check / Markdown lint (push) Successful in 2m27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m44s
CI / Security audit (push) Successful in 3m10s
Docs Check / Mermaid diagram parse check (push) Successful in 3m54s
CI / Tests & coverage (push) Successful in 4m6s
CI / Swagger Validation & Coverage (push) Successful in 4m23s
2026-05-23 20:38:05 +01:00
gronod 49e3261b59 ci: use RELEASE_TOKEN for Gitea Container Registry authentication
CI / Security audit (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m36s
CI / Tests & coverage (push) Successful in 3m0s
Build and Push Docker Image / build (push) Successful in 2m30s
2026-05-23 19:43:28 +01:00
gronod 2934becf32 ci: publish Docker container image to Gitea Package Registry in parallel
CI / Security audit (push) Successful in 1m58s
CI / Swagger Validation & Coverage (push) Successful in 2m25s
CI / Tests & coverage (push) Successful in 3m0s
Build and Push Docker Image / build (push) Failing after 1m38s
2026-05-23 19:01:38 +01:00
gronod 6ff660b8af merge branch 'develop' into 'main' - Release v1.7.6
Create Release / release (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 1m35s
CI / Tests & coverage (push) Successful in 2m46s
CI / Security audit (push) Successful in 1m41s
CI / Swagger Validation & Coverage (push) Successful in 2m22s
2026-05-23 18:55:18 +01:00
gronod 6ac0a8421e fix: resolve rate-limiting and Ombi requests caching bugs (fixes #42, fixes #43)
Build and Push Docker Image / build (push) Successful in 1m34s
Docs Check / Markdown lint (push) Successful in 2m14s
CI / Security audit (push) Successful in 2m30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
Docs Check / Mermaid diagram parse check (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 3m59s
2026-05-23 18:55:03 +01:00
gronod a021ceba47 merge branch 'develop' into 'main' - Release v1.7.5
Build and Push Docker Image / build (push) Successful in 43s
Create Release / release (push) Successful in 54s
CI / Security audit (push) Successful in 1m48s
CI / Tests & coverage (push) Successful in 2m41s
CI / Swagger Validation & Coverage (push) Successful in 2m30s
2026-05-23 10:13:54 +01:00
gronod f8c7e35f31 chore: bump version to 1.7.5 and update CHANGELOG
Docs Check / Markdown lint (push) Successful in 42s
Docs Check / Mermaid diagram parse check (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 4m17s
Build and Push Docker Image / build (push) Successful in 5m5s
CI / Swagger Validation & Coverage (push) Successful in 5m19s
CI / Tests & coverage (push) Successful in 6m27s
2026-05-23 10:13:25 +01:00
gronod de71580756 fix(ombi): retrieve and include settings ID in webhook enable payload (resolves #41)
Build and Push Docker Image / build (push) Successful in 1m10s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 4m35s
CI / Security audit (push) Successful in 5m8s
CI / Swagger Validation & Coverage (push) Successful in 6m50s
CI / Tests & coverage (push) Successful in 7m25s
2026-05-23 10:12:07 +01:00
81 changed files with 6237 additions and 1075 deletions
+10
View File
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
# Useful if those services reside in the same local network/docker container setup and
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
# Example: http://sofarr:3001 or http://192.168.1.50:3001
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
# --- Webhook Polling Optimization (Phase 5) ---
# Minutes of silence after which the poller falls back to a full poll
@@ -162,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# =============================================================================
OMBI_URL=https://ombi.example.com
OMBI_API_KEY=your-ombi-api-key-here
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
# to resolve the race condition where Ombi fires the webhook before committing to its database.
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
# =============================================================================
# NOTES
+21 -6
View File
@@ -6,6 +6,10 @@ on:
- 'release/**'
- 'develop*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
@@ -23,17 +27,28 @@ jobs:
if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building develop image tags: ${TAGS}"
else
RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
# Gitea package registry tags
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}"
echo "Building release image tags: ${TAGS}"
fi
- name: Log into Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.i3omb.com
username: ${{ github.actor }}
password: ${{ secrets.RELEASE_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
+6 -2
View File
@@ -2,9 +2,13 @@ name: CI
on:
push:
branches: ["**"]
branches: ["**", "!release/**"]
pull_request:
branches: ["**"]
branches: ["**", "!release/**"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
audit:
+2 -1
View File
@@ -11,4 +11,5 @@ data/
*.db-wal
*.db-shm
.agents/
.windsurf/
.windsurf/
scratch/
+53 -4
View File
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
Both endpoints share identical processing logic:
```
Sonarr/Radarr
Sonarr/Radarr/Ombi
POST /api/webhook/sonarr
Headers: X-Sofarr-Webhook-Secret: <secret>
Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
"date": "2026-05-19T10:00:00.000Z", … }
@@ -404,6 +404,7 @@ Sonarr/Radarr
validateWebhookSecret() ──fail──► 401 Unauthorized
(Checks header or query param)
│ ok
validatePayload() ──fail──► 400 Bad Request
@@ -994,6 +995,42 @@ For AI agents and automated tooling, every endpoint includes:
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
- Runs the coverage test suite on every push
### 7.5 Real-Time Debug Log Streaming Subsystem
sofarr provides a togglable, real-time log capturing and streaming engine allowing developer-administrators to view server standard output stream activity and browser console log activity for easy debugging in production.
#### Architecture
```mermaid
flowchart LR
subgraph Browser ["Browser (SPA)"]
console["console.log/warn/error"] --> queue["logQueue (batched)"]
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
end
subgraph Server ["Node.js (Server)"]
stdout["process.stdout.write"] --> capture["processStreamData()"]
stderr["process.stderr.write"] --> capture
capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
end
```
#### In-Process Interceptor (Stdout & Stderr)
To guarantee 100% logging completeness without requiring complex and insecure external Docker daemon sockets, the server hooks directly into standard output stream writers `process.stdout.write` and `process.stderr.write` during the process boot cycle.
- Custom accumulators process streams, strip ANSI terminal colors (colors are stripped using a standard regex matching all terminal escape codes), and split incoming chunks into structured lines.
- Historical lines are appended to a rolling in-memory array `logBuffer` capped at 1,000 entries.
- Real-time logging events are broadcasted via a central `EventEmitter` allowing connected SSE stream clients to receive updates instantly.
#### Client Console Log Capture
To assist developers in troubleshooting client-side runtime errors without access to developer consoles (e.g., in embedded WebViews, smart TVs, or custom mobile browser instances), the SPA implements an automatic logging interceptor.
- If `/api/debug/status` indicates log streaming is active, the bootstrap process replaces global `console.log`, `console.warn`, and `console.error` methods.
- Logs are added to an in-memory queue and flushed in batches using a stateless `POST /api/debug/client-logs` request every 2,000ms (or when the queue size reaches 20 items) to prevent browser thread blocking.
- A synchronous cleanup check is registered via standard `beforeunload` to flush any remaining logs using `navigator.sendBeacon()` or `keepalive: true` fetch on page refresh or navigate.
---
## 8. Directory Structure
@@ -1188,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
| Concern | Mechanism |
|---------|-----------|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
@@ -1196,13 +1233,25 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
| Concern | Mechanism |
|---------|-----------|
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
| **Body size** | `express.json` body limit: 64 KB. |
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
### 10.4 Debug Log Streaming Security
Access to the debug log stream endpoints (`/api/debug/server-logs` and `/api/debug/client-logs`) is secured via a strict multi-layered policy:
| Layer | Mechanism |
|-------|-----------|
| **Feature lock toggle** | Strictly disabled by default; only enableable when environment variable `ENABLE_LOG_STREAM=true` is set. |
| **Subnet filtering** | If environment variable `LOG_ALLOW_SUBNETS` is configured (using standard comma-separated CIDR notation), incoming client IPs (`req.ip`) are parsed and validated via the `ipaddr.js` library. Unmatched subnets receive a `403 Forbidden` response immediately. |
| **Fast-path webhook bypass** | A valid webhook secret bypasses the active Emby authentication layer when provided on the `X-Webhook-Secret` header. |
| **Active Emby session** | Validates that an active `emby_user` session cookie is present and that the authenticated Emby user is an administrator. |
| **Emby basic auth fallback** | Allows Basic Authentication headers by performing an on-demand asynchronous credentials check against Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to assert active policy administrator status. |
---
## 11. Technology Stack
+296 -1
View File
@@ -2,7 +2,302 @@
All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).## [1.7.36] - 2026-05-29
### Fixed
- **Test Timeout & Cross-Suite Background Event Pollution (V8 Coverage)** — Configured `fileParallelism: false` and `testTimeout: 15000` in `vitest.config.js`. This guarantees that slow code compilation/instrumentation under V8 coverage doesn't cause transient 5-second timeouts, and prevents asynchronous fire-and-forget background event loops (like Ombi webhook retry loops) in one test suite from running concurrently and overwriting cache singletons in other test suites.
## [1.7.35] - 2026-05-29
### Added
- **Orphaned *arr Queue Item Support (Issue #73)** — Added support for active Sonarr/Radarr queue items from unconfigured download clients ("orphaned" downloads). Added a new synthetic client (`'orphaned'`) with a custom viewBox vector graphics asset at `/images/clients/orphaned.svg` to represent unconfigured clients, and updated filter dropdown lists and active downloads grids to cleanly display them with a dimmed logo, custom dashed border styling, and informative hover tooltips. Resolves Gitea Issue [#73](https://git.i3omb.com/Gandalf/sofarr/issues/73).
### Enhanced
- **Download Matching & JSDoc Hygiene (Issue #73)** — Refactored core active-download matching algorithms into unified, deduplicated helper functions (`normalizeTitle`, `titleMatches`, `buildArrDownload`) in `DownloadMatcher.js`, preventing hundreds of lines of duplicate code. Handled case-insensitive and type-safe `downloadId` lookup in `matchSabHistory` across both history and active queue records. Added safe progress arithmetic bounds checking to prevent division-by-zero or `NaN`.
### Fixed
- **Security Metadata Isolation in buildArrDownload (Issue #73)** — Restricted access control for sensitive properties like `arrInstanceKey` (the raw instance API key) to ensure they are strictly stripped out of download objects for non-administrator users, preserving system security boundaries.
## [1.7.34] - 2026-05-28
### Fixed
- **Webhook Test Duplicate Error (Issue #71)** — Skipped duplicate/replay protection for `"Test"` event types in Sonarr and Radarr webhook handlers, resolving test button failures. Resolves Gitea Issue [#71](https://git.i3omb.com/Gandalf/sofarr/issues/71).
### Enhanced
- **Unified Tab Header Layout & Typography Consistency (Issue #72)** — Added consistent titles, subtitles, and icons across Active Downloads, Recently Completed, and Requests tab panels, and refactored styling to use a unified flexbox design with CSS variables. Resolves Gitea Issue [#72](https://git.i3omb.com/Gandalf/sofarr/issues/72).
## [1.7.33] - 2026-05-28
### Added
- **Requests Tab Layout Enhancement (Issue #69)** — Redesigned and unified the Requests tab container and card layouts with the Active Downloads and Recently Completed tabs. Added styled media-type borders (`tv` and `movie`) using system color variables, styled the `.requests-container` with a surface card background (`var(--surface)`) and box shadow, converted `.requests-list` to a column flexbox (`display: flex; flex-direction: column; gap: 8px;`), aligned card items to the top (`align-items: flex-start`), tighter padding (`10px 14px`), and border-radius (`6px`), and scaled `.request-type-icon` to `48px` wide and `68px` high as a clean cover-art placeholder. All changes are strictly scoped to the requests tab element selectors, leaving active and recent downloads 100% untouched. Resolves Gitea Issue [#69](https://git.i3omb.com/Gandalf/sofarr/issues/69).
### Fixed
- **Webhook Regression (Issue #70)** — Fixed a critical regression introduced in #62 where the Ombi webhook handler called `isReplay()` with 3 arguments instead of the new 4-argument signature (`eventType, instanceName, eventDate, contentId`). The handler now correctly passes `requestId` as the fourth `contentId` argument. This restores reliability to real Ombi webhooks, loopback fallbacks, and the Ombi test simulation buttons. Resolves Gitea Issue [#70](https://git.i3omb.com/Gandalf/sofarr/issues/70).
## [1.7.32] - 2026-05-28
### Fixed
- **TransmissionClient Hardening (Issue #63)** — Mapped the previously-unknown Transmission RPC status code `7` to `Checking` (best-effort; the RPC spec formally documents only codes 06, and the historical alias `TORRENT_IS_CHECKING` corresponds to code 2), so torrents reporting code 7 are now rendered with a useful status label instead of `Unknown`. Implemented three torrent control methods on `TransmissionClient` that were previously absent: `startTorrent(id)` (resumes via `torrent-start`), `stopTorrent(id)` (pauses via `torrent-stop`), and `removeTorrent(id, deleteData = false)` (removes via `torrent-remove`, optionally also deleting local files via Transmission's `delete-local-data` flag). All three accept either a single id (numeric or hash) or an array of ids, matching the Transmission RPC contract. Documented in `extractArrInfo()` that an `arrQueueId` cannot reliably be derived from filename alone — the cross-client matching path is hash-based via `DownloadMatcher.matchTorrents()` (Issue #65), which keys on `torrent.hashString` for Transmission. Added regression tests for status code 7 and all three control methods. Resolves Gitea Issue [#63](https://git.i3omb.com/Gandalf/sofarr/issues/63).
- **Frontend Build Stability (Issue #66)** — Added explanatory inline comments to `client/vite.config.js` documenting two non-standard but deliberate build settings: `build.outDir = '../public'` (the Vite bundle is emitted into the Express-served `public/` directory at the repo root rather than the Vite-default `client/dist/`) and `build.emptyOutDir = false` (required so the hand-authored static assets committed under `public/` are not wiped by every `vite build`). The comments explicitly warn that changing either setting without also updating the Express static-serve configuration in `server/app.js` and the Dockerfile copy steps will break production serving. Removed a stale, untracked `client/dist/` directory (a leftover from an earlier default-Vite build) that was harmless — both `.gitignore` and `.dockerignore` already excluded it from version control and Docker contexts — but caused recurring confusion about which `index.html` was authoritative. Verified `client/index.html` correctly references `/src/main.js` as the Vite entrypoint. Resolves Gitea Issue [#66](https://git.i3omb.com/Gandalf/sofarr/issues/66).
- **Download Matching & Deduplication (Issue #65)** — `DownloadMatcher.matchTorrents()` now attempts hash-first matching for every torrent before falling back to title-substring matching. The hash lookup compares `torrent.hash` (qBittorrent, rTorrent) or `torrent.hashString` (Transmission) against each *arr* queue/history record's `downloadId`, restoring deterministic matching for renamed downloads and torrents whose on-disk filename has diverged from the *arr* release title. Title-substring matching is retained verbatim as a fallback so unhashed clients and legacy fixtures continue to work. After the per-torrent matching pass, the returned list is deduplicated by the composite key `(arrType, arrQueueId)`: the first matched download wins, so a single torrent that maps to N *arr* queue records sharing one queue id (for example, a season pack exposed as multiple per-episode rows) produces a single dashboard card instead of N near-identical duplicates. A new integration suite at `tests/integration/download-matcher-season-pack.test.js` covers hash-first matching for qBittorrent (`hash`) and Transmission (`hashString`), the title-substring fallback path, and the deduplication step. Resolves Gitea Issue [#65](https://git.i3omb.com/Gandalf/sofarr/issues/65).
- **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64).
- **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: <n>` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61).
- **rTorrent Null-Safety, SABnzbd History Limit & Client Last-Error Visibility (Issue #68)** — Three related hardening improvements to the download-client layer. First, `RTorrentClient` now defends against the malformed-response scenarios observed against misconfigured or transiently-restarting rTorrent servers: `getActiveDownloads()` explicitly checks that `d.multicall2` returned an actual array (logging a warning and returning `[]` if not, rather than throwing on `.map`) and processes each torrent row in its own `try`/`catch` so a single malformed entry cannot poison the whole result set. All eleven field values retrieved from the multicall response are coerced to their expected types via explicit `Number()`/`String()` conversions in `normalizeDownload()`, so downstream arithmetic and string operations can no longer blow up on `null` or `undefined` values from plugins or older rTorrent versions. `_extractArrInfo()` now short-circuits safely on non-string filenames. `getClientStatus()` additionally coerces the global rate values through `Number.isFinite` before returning them. Second, the SABnzbd history limit (previously hard-coded to `10` records per poll) is now configurable via the `SAB_HISTORY_LIMIT` environment variable. Invalid or absent values fall back to the default of `10` with a log warning, ensuring backward compatibility. Third, all four download clients (`RTorrentClient`, `SABnzbdClient`, `QBittorrentClient`, `TransmissionClient`) now record structured `lastError` objects (`{ operation, message, at }`) on every failed API call via `_recordLastError()` and clear them on subsequent success via `_clearLastError()` — both helpers introduced on the `DownloadClient` base class alongside the public `getLastError()` accessor. The per-client last-error is surfaced through `DownloadClientRegistry.getAllClientStatuses()` and exposed on the `GET /api/status/status` admin endpoint under the new `downloadClients` array, letting the admin panel show a per-client failure indicator without log scraping. New regression tests cover all null-safety paths, the SAB history limit env variable (unset, valid, invalid, propagated to the API call), and the full lastError set/clear cycle for both rTorrent and SABnzbd. Resolves Gitea Issue [#68](https://git.i3omb.com/Gandalf/sofarr/issues/68).
- **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62).
## [1.7.31] - 2026-05-28
### Fixed
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
## [1.7.30] - 2026-05-28
### Added
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
## [1.7.29] - 2026-05-27
### Fixed
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
## [1.7.28] - 2026-05-27
### Fixed
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
## [1.7.27] - 2026-05-27
### Fixed
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
## [1.7.26] - 2026-05-27
### Fixed
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
## [1.7.25] - 2026-05-27
### Fixed
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
## [1.7.24] - 2026-05-27
### Enhanced
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
## [1.7.23] - 2026-05-27
### Enhanced
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
## [1.7.22] - 2026-05-27
### Fixed
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
### Added
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
---
## [1.7.21] - 2026-05-26
### Fixed
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
---
## [1.7.21] - 2026-05-26
### Fixed
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
---
## [1.7.20] - 2026-05-26
### Fixed
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
### Changed
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
---
## [1.7.19] - 2026-05-25
### Fixed
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
---
## [1.7.18] - 2026-05-24
### Fixed
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
---
## [1.7.17] - 2026-05-24
### Fixed
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
---
## [1.7.16] - 2026-05-24
### Fixed
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
---
## [1.7.15] - 2026-05-24
### Fixed
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
---
## [1.7.14] - 2026-05-24
### Fixed
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
---
## [1.7.13] - 2026-05-24
### Changed
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
### Added
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
---
## [1.7.12] - 2026-05-24
### Added
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
---
## [1.7.11] - 2026-05-24
### Fixed
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
---
## [1.7.10] - 2026-05-24
### Fixed
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
---
## [1.7.9] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.8] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.7] - 2026-05-23
### Fixed
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
---
## [1.7.6] - 2026-05-23
### Fixed
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.5] - 2026-05-23
### Fixed
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
---
+1 -1
View File
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
# Copy application source owned by root (read-only at runtime)
COPY --chown=root:root server/ ./server/
COPY --chown=root:root public/ ./public/
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
COPY --from=client-build --chown=root:root /app/public/ ./public/
COPY --chown=root:root package.json ./
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
+41 -5
View File
@@ -227,6 +227,10 @@ PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable (on-demand mode)
# Debug Log Streaming Subsystem
ENABLE_LOG_STREAM=false # Set to true to enable logs debugging routes
LOG_ALLOW_SUBNETS=127.0.0.1/32 # Comma-separated allowed CIDR blocks (e.g. 127.0.0.1/32,192.168.1.0/24)
```
### Webhooks & Smart Polling
@@ -420,7 +424,7 @@ This approach provides:
### 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).
The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/emby/**) are authenticated proxies to upstream services. These expose a **selective subset** of endpoints from Sonarr, Radarr, SABnzbd, and Emby respectively — not the full upstream API surface. See the API Endpoints section below for the complete list of implemented proxy endpoints.
## API Endpoints
@@ -441,6 +445,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
### History
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
### Debug Logs (requires ENABLE_LOG_STREAM=true)
- `GET /api/debug/status` — Get runtime log stream configurations (public)
- `GET /api/debug/server-logs` — **SSE stream**: push server `stdout`/`stderr` in real-time (requires auth & subnet check)
- `GET /api/debug/client-logs` — **SSE stream**: push ingested frontend console logs in real-time (requires auth & subnet check)
- `POST /api/debug/client-logs` — Ingest batched frontend console logs (requires auth & subnet check)
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
- `POST /api/webhook/radarr` — receive Radarr webhook events
@@ -464,10 +474,36 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` — SABnzbd API proxy
- `GET /api/sonarr/*` — Sonarr API proxy
- `GET /api/radarr/*` — Radarr API proxy
- `GET /api/emby/*` — Emby API proxy
- `GET /api/sabnzbd/queue` — SABnzbd queue
- `GET /api/sabnzbd/history` — SABnzbd history
- `GET /api/sonarr/queue` — Sonarr queue
- `GET /api/sonarr/history` — Sonarr history
- `GET /api/sonarr/series` — Sonarr series list
- `GET /api/sonarr/series/:id` — Sonarr series details
- `GET /api/sonarr/notifications` — Sonarr notifications list
- `GET /api/sonarr/notifications/:id` — Sonarr notification details
- `POST /api/sonarr/notifications` — Create Sonarr notification
- `PUT /api/sonarr/notifications/:id` — Update Sonarr notification
- `DELETE /api/sonarr/notifications/:id` — Delete Sonarr notification
- `POST /api/sonarr/notifications/test` — Test Sonarr notification
- `GET /api/sonarr/notifications/schema` — Sonarr notification schema
- `POST /api/sonarr/notifications/sofarr-webhook` — One-click Sofarr webhook setup
- `GET /api/radarr/queue` — Radarr queue
- `GET /api/radarr/history` — Radarr history
- `GET /api/radarr/movies` — Radarr movies list
- `GET /api/radarr/movies/:id` — Radarr movie details
- `GET /api/radarr/notifications` — Radarr notifications list
- `GET /api/radarr/notifications/:id` — Radarr notification details
- `POST /api/radarr/notifications` — Create Radarr notification
- `PUT /api/radarr/notifications/:id` — Update Radarr notification
- `DELETE /api/radarr/notifications/:id` — Delete Radarr notification
- `POST /api/radarr/notifications/test` — Test Radarr notification
- `GET /api/radarr/notifications/schema` — Radarr notification schema
- `POST /api/radarr/notifications/sofarr-webhook` — One-click Sofarr webhook setup
- `GET /api/emby/sessions` — Emby active sessions
- `GET /api/emby/users` — Emby users list
- `GET /api/emby/users/:id` — Emby user details
- `GET /api/emby/session/:sessionId/user` — Emby user from session
## Logging Levels
+8 -7
View File
@@ -40,7 +40,7 @@ users via Emby. The primary threat surface when exposed to the public internet:
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
@@ -162,12 +162,13 @@ server {
## Rate Limits
| Endpoint | Limit |
|----------|-------|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
| All `/api/*` routes | 300 requests per 15 min per IP |
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
| `GET /api/swagger` | No rate limit (public documentation) |
| Endpoint | Limit | Details & Exemptions |
|----------|-------|----------------------|
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
---
+1 -1
View File
@@ -7,6 +7,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2
View File
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentIds: download.arrContentIds,
arrSeriesId: download.arrSeriesId,
arrContentType: download.arrContentType
})
});
+4
View File
@@ -11,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
import { initTabs, goHome } from './ui/tabs.js';
import { handleShowAllToggle } from './sse.js';
import { loadAppVersion } from './api.js';
import { initClientLogCapture } from './utils/clientLogCapture.js';
document.addEventListener('DOMContentLoaded', () => {
// Initialize client console log capturing early
initClientLogCapture();
// Login form
const loginForm = document.getElementById('login-form');
if (loginForm) {
+9 -2
View File
@@ -35,12 +35,17 @@ export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
function createClientLogo(download) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
if (download.isOrphaned) {
clientLogoWrapper.classList.add('orphaned-logo');
}
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.title = download.isOrphaned
? "This download is managed by a *arr instance's download client that is not configured in Sofarr."
: (download.instanceName || download.client);
clientLogo.onerror = () => {
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
@@ -272,6 +277,8 @@ export async function handleBlocklistSearchClick(btn, download) {
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentIds: download.arrContentIds,
arrSeriesId: download.arrSeriesId,
arrContentType: download.arrContentType,
isAdmin: state.isAdmin,
canBlocklist: download.canBlocklist
@@ -301,7 +308,7 @@ export async function handleBlocklistSearchClick(btn, download) {
export function createDownloadCard(download) {
const card = document.createElement('div');
card.className = `download-card ${download.type}`;
card.className = `download-card ${download.type}${download.isOrphaned ? ' orphaned' : ''}`;
card.dataset.id = download.title;
// Cover art
+100 -20
View File
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
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 || '';
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
const userSource = request.requestedUser || request.RequestedUser ||
request.user || request.User ||
request.requestedBy || request.RequestedBy ||
request.ombiUser || request.OmbiUser ||
request.requestedByUser || request.RequestedByUser;
// If userSource is an object, extract key fields
if (userSource && typeof userSource === 'object') {
const username = userSource.alias || userSource.Alias ||
userSource.userAlias || userSource.UserAlias ||
userSource.userName || userSource.UserName ||
userSource.normalizedUserName || userSource.NormalizedUserName ||
userSource.displayName || userSource.DisplayName ||
userSource.email || userSource.Email;
if (username) return username;
}
// Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || '';
// If userSource is a string
if (userSource && typeof userSource === 'string') {
return userSource;
}
// Fallbacks on the request root level
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
request.requestedByUsername || request.RequestedByUsername ||
request.requester || request.Requester ||
request.requestedByEmail || request.RequestedByEmail;
if (rootFallback) return rootFallback;
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
if (Array.isArray(request.seasons)) {
for (const season of request.seasons) {
const seasonUser = extractRequestedUser(season);
if (seasonUser) return seasonUser;
}
}
if (Array.isArray(request.childRequests)) {
for (const child of request.childRequests) {
const childUser = extractRequestedUser(child);
if (childUser) return childUser;
}
}
return '';
}
export function renderRequests() {
@@ -84,7 +119,7 @@ function createRequestCard(request) {
}
const card = document.createElement('div');
card.className = 'request-card';
card.className = `request-card ${request.mediaType || ''}`;
const typeIcon = document.createElement('span');
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
@@ -111,11 +146,39 @@ function createRequestCard(request) {
}
const username = extractRequestedUser(request);
const user = document.createElement('span');
user.className = 'request-user';
if (username) {
const user = document.createElement('span');
user.className = 'request-user';
user.textContent = `Requested by: ${username}`;
meta.appendChild(user);
} else {
user.textContent = 'Requested by: Unknown (Ombi)';
user.title = 'No user information received from Ombi';
user.style.cursor = 'help';
user.style.textDecoration = 'underline dotted';
}
meta.appendChild(user);
const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
if (dateStr) {
const requestDate = document.createElement('span');
requestDate.className = 'request-date';
try {
const dateObj = new Date(dateStr);
if (!isNaN(dateObj.getTime())) {
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
const hours = String(dateObj.getHours()).padStart(2, '0');
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
} else {
requestDate.textContent = `Date: ${dateStr}`;
}
} catch (e) {
requestDate.textContent = `Date: ${dateStr}`;
}
meta.appendChild(requestDate);
}
if (request.quality) {
@@ -128,25 +191,42 @@ function createRequestCard(request) {
content.appendChild(title);
content.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'request-actions';
const actions = document.createElement('span');
actions.className = 'service-icons-container';
if (state.ombiBaseUrl && request.theMovieDbId) {
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
if (state.ombiBaseUrl && id) {
const ombiLink = document.createElement('a');
ombiLink.className = 'request-link ombi-link';
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
ombiLink.className = 'ombi-link';
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
ombiLink.target = '_blank';
ombiLink.title = 'View in Ombi';
const ombiIcon = document.createElement('img');
ombiIcon.className = 'service-icon ombi';
ombiIcon.src = '/images/ombi.svg';
ombiIcon.alt = 'Ombi';
ombiIcon.className = 'request-icon';
ombiLink.appendChild(ombiIcon);
actions.appendChild(ombiLink);
}
if (state.isAdmin && request.arrLink) {
const arrLink = document.createElement('a');
arrLink.className = `${request.arrType}-link`;
arrLink.href = request.arrLink;
arrLink.target = '_blank';
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
const arrIcon = document.createElement('img');
arrIcon.className = `service-icon ${request.arrType}`;
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
arrLink.appendChild(arrIcon);
actions.appendChild(arrLink);
}
card.appendChild(typeIcon);
card.appendChild(content);
card.appendChild(actions);
+6 -2
View File
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
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 status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</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>`;
}
+29 -10
View File
@@ -4,24 +4,43 @@ 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);
}
const theme = getTheme() || 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
export function initThemeSwitcher() {
const themeToggle = document.getElementById('theme-toggle');
if (!themeToggle) return;
const themeButtons = document.querySelectorAll('.theme-btn');
const currentTheme = getTheme() || 'light';
themeToggle.addEventListener('click', () => {
const currentTheme = getTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
// Set initial active state on buttons
themeButtons.forEach(btn => {
if (btn.getAttribute('data-theme') === currentTheme) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
btn.addEventListener('click', () => {
const theme = btn.getAttribute('data-theme');
if (theme) {
setTheme(theme);
}
});
});
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
saveTheme(theme);
// Sync button active classes if elements are present on the page
const themeButtons = document.querySelectorAll('.theme-btn');
themeButtons.forEach(btn => {
if (btn.getAttribute('data-theme') === theme) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const logQueue = [];
const MAX_QUEUE_SIZE = 20;
const FLUSH_INTERVAL_MS = 2000;
// Original console functions
const originalLog = console.log;
const originalWarn = console.warn;
const originalError = console.error;
let isSending = false;
let isInitialized = false;
let flushInterval = null;
function formatArgs(args) {
return args.map(arg => {
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
}
if (typeof arg === 'object') {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
return String(arg);
}).join(' ');
}
function enqueue(level, args) {
const formattedMsg = formatArgs(args);
// Still write to the developer console!
if (level === 'info') originalLog.apply(console, args);
else if (level === 'warn') originalWarn.apply(console, args);
else if (level === 'error') originalError.apply(console, args);
// Guard against infinite loop during logs dispatching
if (isSending) return;
logQueue.push({
timestamp: new Date().toISOString(),
level,
message: formattedMsg
});
// Flush immediately if queue is full
if (logQueue.length >= MAX_QUEUE_SIZE) {
flushQueue();
}
}
async function flushQueue() {
if (logQueue.length === 0 || isSending) return;
isSending = true;
const batch = [...logQueue];
logQueue.length = 0;
try {
const response = await fetch('/api/debug/client-logs', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(batch),
// keepalive allows request to survive page unload
keepalive: true
});
if (!response.ok) {
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
}
} catch (err) {
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
} finally {
isSending = false;
}
}
// Perform a fast/unblocked payload flush using sendBeacon on page unload
function flushOnUnload() {
if (logQueue.length === 0) return;
const batch = [...logQueue];
logQueue.length = 0;
try {
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
navigator.sendBeacon('/api/debug/client-logs', blob);
} catch (err) {
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
try {
fetch('/api/debug/client-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
keepalive: true
});
} catch {
// Ignore
}
}
}
export async function initClientLogCapture() {
if (isInitialized) return;
try {
// 1. Check if the server toggle for logging is active
const response = await fetch('/api/debug/status');
if (!response.ok) return;
const data = await response.json();
if (data && data.enabled === true) {
// 2. Override global console methods
console.log = (...args) => enqueue('info', args);
console.warn = (...args) => enqueue('warn', args);
console.error = (...args) => enqueue('error', args);
// 3. Set interval for batch updates
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
// 4. Setup beforeunload listener for clean flushing
window.addEventListener('beforeunload', flushOnUnload);
isInitialized = true;
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
}
} catch (err) {
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
}
}
+17
View File
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
if (request.denied) return 'denied';
if (request.approved) return 'approved';
if (request.requested) return 'pending';
// Ombi TV requests store status flags inside childRequests
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
for (const child of request.childRequests) {
if (child && child.available) return 'available';
}
for (const child of request.childRequests) {
if (child && child.denied) return 'denied';
}
for (const child of request.childRequests) {
if (child && child.approved) return 'approved';
}
for (const child of request.childRequests) {
if (child && child.requested) return 'pending';
}
}
return 'unknown';
}
+46 -24
View File
@@ -1,28 +1,50 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import path from 'path'
export default defineConfig({
build: {
outDir: '../public',
emptyOutDir: false,
rollupOptions: {
input: {
main: './src/main.js'
},
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
export default defineConfig(({ mode }) => {
// Load env variables from root directory to match backend TLS configuration
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
const port = env.PORT || 3001;
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
return {
build: {
// NOTE (Issue #66): `outDir` is intentionally the repo-root `../public`,
// NOT the Vite default `client/dist/`. The Express server in
// `server/app.js` serves static assets directly from `public/`, so the
// Vite build emits its bundle alongside the hand-authored static assets
// (favicon, etc.) that live in `public/` and are committed to the repo.
// Do NOT change this back to `dist/` without also updating the Express
// static-serve configuration and the Dockerfile copy steps.
outDir: '../public',
// NOTE (Issue #66): `emptyOutDir: false` is REQUIRED because `public/`
// contains hand-authored static assets that must survive the build.
// Setting this to `true` would wipe those assets on every `vite build`.
emptyOutDir: false,
rollupOptions: {
input: {
main: './src/main.js'
},
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
},
server: {
port: 5173,
host: true, // Listen on all network interfaces
proxy: {
'/api': {
target: target,
changeOrigin: true,
secure: false // Allow self-signed certificate in development
}
}
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})
};
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "1.7.4",
"version": "1.7.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.4",
"version": "1.7.36",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+5 -2
View File
@@ -1,11 +1,14 @@
{
"name": "sofarr",
"version": "1.7.4",
"version": "1.7.36",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
"dev": "nodemon server/index.js",
"dev:server": "nodemon server/index.js",
"dev:client": "npm run dev --prefix client",
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
"start": "node server/index.js",
"build": "npm run build --prefix client",
"install:all": "npm install",
"test": "vitest run",
"test:watch": "vitest",
+22 -20
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<circle cx="256" cy="256" r="240" fill="#f5f5f7" stroke="#d2d2d7" stroke-width="20"/>
<text x="50%" y="60%" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="300px" font-weight="bold" fill="#86868b" text-anchor="middle" dominant-baseline="middle">?</text>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+18 -6
View File
@@ -170,8 +170,12 @@
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div class="downloads-header">
<div class="downloads-controls">
<div class="downloads-header tab-header">
<div class="tab-header-title">
<h2><span class="tab-header-icon">📥</span> Active Downloads</h2>
<p class="tab-header-subtitle">Track and manage your active media downloads in real-time</p>
</div>
<div class="downloads-controls tab-header-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">
@@ -200,8 +204,12 @@
<div class="tab-panel hidden" id="tab-requests">
<div class="requests-container">
<div class="requests-header">
<div class="requests-controls">
<div class="requests-header tab-header">
<div class="tab-header-title">
<h2><span class="tab-header-icon">📨</span> Requests</h2>
<p class="tab-header-subtitle">Browse, filter, and track requests synced from Ombi</p>
</div>
<div class="requests-controls tab-header-controls">
<!-- Media Type Filter -->
<div class="request-filter" id="request-type-filter">
<label class="request-filter-label">Type:</label>
@@ -286,8 +294,12 @@
<div class="tab-panel hidden" id="tab-history">
<div class="history-container" id="history-container">
<div class="history-header">
<div class="history-controls">
<div class="history-header tab-header">
<div class="tab-header-title">
<h2><span class="tab-header-icon">📜</span> Recently Completed</h2>
<p class="tab-header-subtitle">Review successful imports and troubleshoot failed upgrade attempts</p>
</div>
<div class="history-controls tab-header-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>
+109 -42
View File
@@ -689,15 +689,61 @@ body {
padding: 0;
}
/* Downloads header and controls */
.downloads-header {
/* Unified Tab Headers (Issue #72) */
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.tab-header-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.tab-header-title h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.tab-header-icon {
font-size: 1.3rem;
display: inline-flex;
align-items: center;
}
.tab-header-subtitle {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.tab-header-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.tab-header, .tab-header-title h2, .tab-header-subtitle {
transition: color 0.3s, border-color 0.3s;
}
.downloads-header {
/* Inherits from .tab-header */
}
.downloads-controls {
display: flex;
align-items: center;
@@ -898,11 +944,7 @@ body {
/* ===== Request Filters ===== */
.requests-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
/* Inherits from .tab-header */
}
.requests-controls {
@@ -1076,18 +1118,7 @@ body {
}
.history-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.history-header h2 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
flex: 1 1 auto;
/* Inherits from .tab-header */
}
.history-controls {
@@ -1134,7 +1165,7 @@ body {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.82rem;
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
@@ -1888,6 +1919,23 @@ body {
/* ===== Mobile ===== */
@media (max-width: 768px) {
.main-tabs {
padding: 0 8px;
}
.requests-container {
padding: 8px;
}
.request-card {
gap: 8px;
padding: 10px;
}
.request-meta {
gap: 4px;
}
.app {
padding: 10px;
}
@@ -2212,17 +2260,15 @@ body {
/* ===== Requests Tab ===== */
.requests-container {
padding: 20px;
background: var(--surface);
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow);
transition: background 0.3s;
}
.requests-header {
margin-bottom: 20px;
}
.requests-header h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
/* Inherits from .tab-header */
}
.no-requests {
@@ -2232,19 +2278,29 @@ body {
}
.requests-list {
display: grid;
gap: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.request-card {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
padding: 16px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 6px;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
min-width: 0;
}
.request-card.tv {
border-left: 3px solid var(--series-color);
}
.request-card.movie {
border-left: 3px solid var(--movie-color);
}
.request-card:hover {
@@ -2253,14 +2309,15 @@ body {
}
.request-type-icon {
font-size: 1.5rem;
font-size: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 48px;
height: 68px;
background: var(--surface-alt);
border-radius: 8px;
border-radius: 4px;
box-shadow: 0 1px 4px var(--shadow-strong);
flex-shrink: 0;
}
@@ -2270,12 +2327,11 @@ body {
}
.request-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 4px;
word-break: break-word;
}
.request-meta {
@@ -2363,3 +2419,14 @@ body {
width: 20px;
}
/* ===== Orphaned Download Styling ===== */
.download-card.orphaned {
border-left: 3px dashed var(--border-color, #c8c8cc);
opacity: 0.95;
}
.download-client-logo-wrapper.orphaned-logo {
filter: grayscale(1) opacity(0.5);
cursor: help;
}
+48 -1
View File
@@ -15,6 +15,8 @@ const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const YAML = require('yamljs');
const path = require('path');
const fs = require('fs');
const { version } = require('../package.json');
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
@@ -26,6 +28,7 @@ const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const debugRoutes = require('./routes/debug');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
@@ -96,6 +99,7 @@ function createApp({ skipRateLimits = false } = {}) {
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' }
});
@@ -126,13 +130,17 @@ function createApp({ skipRateLimits = false } = {}) {
* type: number
* description: Server uptime in seconds
* example: 3600.5
* version:
* type: string
* description: sofarr version
* example: "1.7.36"
* x-code-samples:
* - lang: curl
* label: cURL
* source: curl http://localhost:3001/health
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
res.json({ status: 'ok', uptime: process.uptime(), version });
});
/**
@@ -212,6 +220,7 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
app.use('/api/debug', debugRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);
@@ -224,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// Global error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
+35
View File
@@ -25,6 +25,41 @@ class DownloadClient {
this.apiKey = instanceConfig.apiKey;
this.username = instanceConfig.username;
this.password = instanceConfig.password;
// Last error encountered while talking to this client.
// Cleared on successful calls via _clearLastError(); set via _recordLastError().
// Surfaced through getAllClientStatuses() so the admin status panel can show
// a per-client failure indicator without needing to scrape logs.
this.lastError = null;
}
/**
* Record an error encountered while talking to this client.
* @param {string} operation - Short description of the operation (e.g. 'getActiveDownloads')
* @param {Error|string} error - Error object or message
*/
_recordLastError(operation, error) {
const message = (error && error.message) ? error.message : String(error || 'unknown error');
this.lastError = {
operation,
message,
at: new Date().toISOString()
};
}
/**
* Clear the last error (called when an operation succeeds).
*/
_clearLastError() {
this.lastError = null;
}
/**
* Public accessor for the last recorded error, or null if none.
* @returns {{operation:string, message:string, at:string}|null}
*/
getLastError() {
return this.lastError;
}
/**
+14
View File
@@ -125,6 +125,20 @@ class OmbiClient {
return false;
}
}
/**
* Get all users from Ombi
* @returns {Promise<Array>} Array of user objects
*/
async getUsers() {
try {
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
return response.data || [];
} catch (error) {
logToFile(`[OmbiClient] Get users error: ${error.message}`);
return [];
}
}
}
module.exports = OmbiClient;
+122 -16
View File
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
this.cache = {
movieRequests: [],
tvRequests: [],
users: [],
movieMap: new Map(), // tmdbId -> request
tvMap: new Map(), // tvdbId -> request
userMap: new Map(), // id -> user
lastFetch: 0,
ttl: 5 * 60 * 1000 // 5 minutes TTL
};
@@ -87,30 +89,43 @@ class OmbiRetriever extends ArrRetriever {
/**
* Refresh cached data from Ombi API
* @param {boolean} force - Whether to force a refresh regardless of TTL
* @returns {Promise<void>}
*/
async refreshCache() {
if (!this.isCacheExpired()) {
async refreshCache(force = false) {
if (!force && !this.isCacheExpired()) {
return;
}
try {
logToFile('[OmbiRetriever] Refreshing cache');
// Fetch requests in parallel
const [movieRequests, tvRequests] = await Promise.all([
// Fetch requests and users in parallel
const [movieRequests, tvRequests, users] = await Promise.all([
this.client.getMovieRequests(),
this.client.getTvRequests()
this.client.getTvRequests(),
this.client.getUsers()
]);
// Update cache
this.cache.movieRequests = movieRequests;
this.cache.tvRequests = tvRequests;
this.cache.users = users;
this.cache.lastFetch = Date.now();
// Build lookup maps
this.cache.movieMap.clear();
this.cache.tvMap.clear();
this.cache.userMap.clear();
// Build user map (id -> user)
if (Array.isArray(users)) {
users.forEach(user => {
if (user && user.id) {
this.cache.userMap.set(user.id, user);
}
});
}
// Build movie map (tmdbId -> request)
movieRequests.forEach(request => {
@@ -132,29 +147,120 @@ class OmbiRetriever extends ArrRetriever {
}
});
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
} catch (error) {
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
// Don't throw error, continue with stale cache if available
}
}
/**
* Hydrates requestedUser on a single request using the userMap cache
* @param {Object} req - The request object
* @returns {Object} Hydrated request object
* @private
*/
_hydrateRequest(req) {
if (!req) return req;
let result = req;
const reqUserId = req.requestedUserId || req.RequestedUserId;
if (reqUserId && this.cache.userMap.has(reqUserId)) {
const cachedUser = this.cache.userMap.get(reqUserId);
let requestedUser = req.requestedUser || req.RequestedUser;
// If requestedUser is not an object or is empty/null, populate it
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
const hydratedUser = {
id: cachedUser.id,
userName: cachedUser.userName,
alias: cachedUser.alias || cachedUser.Alias || '',
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
};
result = {
...req,
requestedUser: hydratedUser,
RequestedUser: hydratedUser
};
}
}
// Hydrate childRequests (common for Ombi TV show requests)
if (Array.isArray(result.childRequests) && result.childRequests.length > 0) {
const hydratedChildren = result.childRequests.map(child => {
if (!child) return child;
const childUserId = child.requestedUserId || child.RequestedUserId;
if (childUserId && this.cache.userMap.has(childUserId)) {
const cachedUser = this.cache.userMap.get(childUserId);
let childUser = child.requestedUser || child.RequestedUser;
if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) {
const hydratedUser = {
id: cachedUser.id,
userName: cachedUser.userName,
alias: cachedUser.alias || cachedUser.Alias || '',
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
};
return {
...child,
requestedUser: hydratedUser,
RequestedUser: hydratedUser
};
}
}
return child;
});
result = { ...result, childRequests: hydratedChildren };
}
// Promote requestedDate from childRequests to top level (common for Ombi TV)
if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) {
const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate;
if (childDate) {
result = { ...result, requestedDate: childDate };
}
}
return result;
}
/**
* Hydrates requestedUser on a list of requests using the userMap cache
* @param {Array} requests - Array of request objects
* @returns {Array} Array of hydrated request objects
* @private
*/
_hydrateRequests(requests) {
if (!Array.isArray(requests)) return [];
return requests.map(req => this._hydrateRequest(req));
}
/**
* Get all movie requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of movie request objects
*/
async getMovieRequests() {
await this.refreshCache();
return this.cache.movieRequests;
async getMovieRequests(force = false) {
await this.refreshCache(force);
return this._hydrateRequests(this.cache.movieRequests);
}
/**
* Get all TV requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of TV request objects
*/
async getTvRequests() {
await this.refreshCache();
return this.cache.tvRequests;
async getTvRequests(force = false) {
await this.refreshCache(force);
return this._hydrateRequests(this.cache.tvRequests);
}
/**
@@ -168,12 +274,12 @@ class OmbiRetriever extends ArrRetriever {
// Try TMDB ID first
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
return this.cache.movieMap.get(tmdbId);
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
}
// Try IMDB ID as fallback
if (imdbId && this.cache.movieMap.has(imdbId)) {
return this.cache.movieMap.get(imdbId);
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
}
return null;
@@ -190,12 +296,12 @@ class OmbiRetriever extends ArrRetriever {
// Try TVDB ID first
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
return this.cache.tvMap.get(tvdbId);
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
}
// Try TMDB ID as fallback
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
return this.cache.tvMap.get(tmdbId);
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
}
return null;
+19
View File
@@ -23,9 +23,11 @@ class QBittorrentClient extends DownloadClient {
// Try a simple API call to verify connection
await this.makeRequest('/api/v2/app/version');
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
@@ -104,6 +106,11 @@ class QBittorrentClient extends DownloadClient {
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
const data = response.data;
if (!data) {
logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`);
return Array.from(this.torrentMap.values());
}
if (data.full_update) {
// Full refresh: rebuild the entire map
this.torrentMap.clear();
@@ -169,6 +176,7 @@ class QBittorrentClient extends DownloadClient {
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
this._clearLastError();
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
@@ -183,6 +191,7 @@ class QBittorrentClient extends DownloadClient {
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
this._recordLastError('getActiveDownloads', fallbackError);
return [];
}
}
@@ -193,6 +202,7 @@ class QBittorrentClient extends DownloadClient {
const response = await this.makeRequest('/api/v2/sync/maindata');
const data = response.data;
this._clearLastError();
return {
serverState: data.server_state || {},
rid: data.rid,
@@ -200,6 +210,7 @@ class QBittorrentClient extends DownloadClient {
};
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
@@ -249,6 +260,14 @@ class QBittorrentClient extends DownloadClient {
downloaded: downloadedSize,
speed: torrent.dlspeed,
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
// Connected peer counts (Issue #64). qBittorrent exposes:
// num_seeds — connected seeds (peers we have a connection to)
// num_leechs — connected leechers (peers downloading from us)
// num_complete / num_incomplete — *swarm* totals reported by tracker
// We expose the connected counts to stay consistent with what other
// clients (e.g. Transmission via peersConnected/peersSendingToUs) report.
seeds: torrent.num_seeds ?? 0,
peers: torrent.num_leechs ?? 0,
category: torrent.category || undefined,
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
savePath: torrent.content_path || torrent.save_path || undefined,
+64 -14
View File
@@ -35,9 +35,11 @@ class RTorrentClient extends DownloadClient {
try {
await this._methodCall('system.client_version');
logToFile(`[rtorrent:${this.name}] Connection test successful`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
@@ -77,10 +79,31 @@ class RTorrentClient extends DownloadClient {
'd.custom1='
]);
// rTorrent XML-RPC can occasionally return null/undefined or a non-array
// on misconfigured servers or transient errors. Guard against that here
// so callers always get a sane array instead of throwing on .map.
if (!Array.isArray(torrents)) {
logToFile(`[rtorrent:${this.name}] d.multicall2 returned non-array (${typeof torrents}); treating as empty`);
this._clearLastError();
return [];
}
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
this._clearLastError();
// Filter out any individual rows that fail to normalize so a single bad
// record cannot poison the whole result set.
const normalized = [];
for (const torrent of torrents) {
try {
normalized.push(this.normalizeDownload(torrent));
} catch (err) {
logToFile(`[rtorrent:${this.name}] Skipping malformed torrent row: ${err.message}`);
}
}
return normalized;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
this._recordLastError('getActiveDownloads', error);
return [];
}
}
@@ -92,31 +115,53 @@ class RTorrentClient extends DownloadClient {
this._methodCall('throttle.global_up.rate')
]);
this._clearLastError();
return {
globalDownRate: downRate,
globalUpRate: upRate
globalDownRate: Number.isFinite(downRate) ? downRate : 0,
globalUpRate: Number.isFinite(upRate) ? upRate : 0
};
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
normalizeDownload(torrent) {
// rTorrent's d.multicall2 returns an array of fields in the order requested.
// If a value is missing rtorrent typically returns '' or 0, but plugins and
// older versions can return undefined/null — coerce everything explicitly so
// downstream math and string ops never blow up on null/undefined.
if (!Array.isArray(torrent)) {
throw new Error('Expected torrent row to be an array');
}
const [
hash,
name,
sizeBytes,
completedBytes,
downRate,
upRate,
state,
isActive,
isHashChecking,
directory,
custom1
hashRaw,
nameRaw,
sizeBytesRaw,
completedBytesRaw,
downRateRaw,
upRateRaw,
stateRaw,
isActiveRaw,
isHashCheckingRaw,
directoryRaw,
custom1Raw
] = torrent;
const hash = hashRaw ? String(hashRaw) : '';
const name = nameRaw ? String(nameRaw) : '';
const sizeBytes = Number(sizeBytesRaw) || 0;
const completedBytes = Number(completedBytesRaw) || 0;
const downRate = Number(downRateRaw) || 0;
const upRate = Number(upRateRaw) || 0;
const state = Number.isFinite(Number(stateRaw)) ? Number(stateRaw) : 0;
const isActive = Number.isFinite(Number(isActiveRaw)) ? Number(isActiveRaw) : 0;
const isHashChecking = Number.isFinite(Number(isHashCheckingRaw)) ? Number(isHashCheckingRaw) : 0;
const directory = directoryRaw ? String(directoryRaw) : '';
const custom1 = custom1Raw ? String(custom1Raw) : '';
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
@@ -168,6 +213,11 @@ class RTorrentClient extends DownloadClient {
}
_extractArrInfo(filename) {
// Null-safe: getActiveDownloads passes a normalized string, but guard anyway
// so callers passing raw rtorrent values cannot crash this helper.
if (!filename || typeof filename !== 'string') {
return {};
}
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
+30 -3
View File
@@ -3,9 +3,27 @@ const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
// Number of recently completed jobs to pull from SABnzbd's /api?mode=history on
// every poll. Larger values let DownloadMatcher correlate slightly older jobs
// with their Sonarr/Radarr queue entries at the cost of one wider HTTP
// response per poll cycle. Configurable via the SAB_HISTORY_LIMIT environment
// variable; defaults to 10 to match the previous hardcoded value.
const DEFAULT_HISTORY_LIMIT = 10;
function resolveHistoryLimit() {
const raw = process.env.SAB_HISTORY_LIMIT;
if (raw === undefined || raw === null || raw === '') return DEFAULT_HISTORY_LIMIT;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
logToFile(`[SABnzbd] Invalid SAB_HISTORY_LIMIT='${raw}'; falling back to ${DEFAULT_HISTORY_LIMIT}`);
return DEFAULT_HISTORY_LIMIT;
}
return parsed;
}
class SABnzbdClient extends DownloadClient {
constructor(instance) {
super(instance);
this.historyLimit = resolveHistoryLimit();
}
getClientType() {
@@ -16,9 +34,11 @@ class SABnzbdClient extends DownloadClient {
try {
const response = await this.makeRequest('', { mode: 'version' });
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
@@ -47,7 +67,7 @@ class SABnzbdClient extends DownloadClient {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 })
this.makeRequest({ mode: 'history', limit: this.historyLimit })
]);
const queueData = queueResponse.data;
@@ -99,10 +119,12 @@ class SABnzbdClient extends DownloadClient {
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads (historyLimit=${this.historyLimit})`);
this._clearLastError();
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
this._recordLastError('getActiveDownloads', error);
return [];
}
}
@@ -112,8 +134,12 @@ class SABnzbdClient extends DownloadClient {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) return null;
if (!queueData) {
this._clearLastError();
return null;
}
this._clearLastError();
return {
status: queueData.status,
speed: queueData.speed,
@@ -128,6 +154,7 @@ class SABnzbdClient extends DownloadClient {
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
+50 -4
View File
@@ -18,9 +18,11 @@ class TransmissionClient extends DownloadClient {
try {
await this.makeRequest('session-get');
logToFile(`[Transmission:${this.name}] Connection test successful`);
this._clearLastError();
return true;
} catch (error) {
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
this._recordLastError('testConnection', error);
return false;
}
}
@@ -80,10 +82,11 @@ class TransmissionClient extends DownloadClient {
const torrents = response.data.arguments.torrents || [];
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
this._clearLastError();
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
this._recordLastError('getActiveDownloads', error);
return [];
}
}
@@ -93,12 +96,14 @@ class TransmissionClient extends DownloadClient {
const response = await this.makeRequest('session-get');
const sessionStats = await this.makeRequest('session-stats');
this._clearLastError();
return {
session: response.data.arguments,
stats: sessionStats.data.arguments
};
} catch (error) {
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
this._recordLastError('getClientStatus', error);
return null;
}
}
@@ -113,7 +118,11 @@ class TransmissionClient extends DownloadClient {
4: 'Downloading', // TORRENT_DOWNLOAD
5: 'Queued', // TORRENT_SEED_WAIT
6: 'Seeding', // TORRENT_SEED
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
// Status code 7 is undocumented in the Transmission RPC spec (which
// formally defines only 06). The legacy alias "TORRENT_IS_CHECKING"
// (a duplicate of code 2) is the best-effort interpretation; map it to
// `Checking` so it is rendered usefully rather than as `Unknown`.
7: 'Checking' // TORRENT_IS_CHECKING (undocumented; treated as code 2)
};
const status = statusMap[torrent.status] || 'Unknown';
@@ -160,8 +169,12 @@ class TransmissionClient extends DownloadClient {
}
extractArrInfo(filename) {
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
// arrQueueId cannot be extracted from filename alone; *arr exposes that
// identifier only via its queue API. The reliable cross-client matching
// path is hash-based and lives in `DownloadMatcher.matchTorrents()` (see
// Issue #65), which keys on `torrent.hashString` for Transmission.
// This heuristic remains only to provide a coarse `type` hint.
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
@@ -176,6 +189,39 @@ class TransmissionClient extends DownloadClient {
return {};
}
/**
* Start (resume) one or more torrents. `id` is the Transmission internal
* numeric id or a hashString; the RPC accepts either.
* @param {number|string|Array<number|string>} id
*/
async startTorrent(id) {
const ids = Array.isArray(id) ? id : [id];
await this.makeRequest('torrent-start', { ids });
logToFile(`[Transmission:${this.name}] Started torrent(s): ${ids.join(', ')}`);
}
/**
* Stop (pause) one or more torrents.
* @param {number|string|Array<number|string>} id
*/
async stopTorrent(id) {
const ids = Array.isArray(id) ? id : [id];
await this.makeRequest('torrent-stop', { ids });
logToFile(`[Transmission:${this.name}] Stopped torrent(s): ${ids.join(', ')}`);
}
/**
* Remove one or more torrents. When `deleteData` is true the local files
* are also deleted from disk (Transmission's `delete-local-data`).
* @param {number|string|Array<number|string>} id
* @param {boolean} [deleteData=false]
*/
async removeTorrent(id, deleteData = false) {
const ids = Array.isArray(id) ? id : [id];
await this.makeRequest('torrent-remove', { ids, 'delete-local-data': !!deleteData });
logToFile(`[Transmission:${this.name}] Removed torrent(s): ${ids.join(', ')} (deleteData=${!!deleteData})`);
}
}
module.exports = TransmissionClient;
+4 -284
View File
@@ -13,6 +13,8 @@ const swaggerJsdoc = require('swagger-jsdoc');
const YAML = require('yamljs');
require('dotenv').config();
require('./utils/loadSecrets')();
const logCapture = require('./utils/logCapture');
logCapture.init();
const { version } = require('../package.json');
// Setup logging with levels
@@ -80,19 +82,9 @@ console.error = function(...args) {
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
};
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config');
const { createApp } = require('./app');
// ---------------------------------------------------------------------------
// Startup environment validation
@@ -114,282 +106,10 @@ if (process.env.EMBY_URL) {
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
}
const app = express();
const app = createApp();
const PORT = process.env.PORT || 3001;
// 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
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
// ---------------------------------------------------------------------------
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
// req.ip reflects the real client IP (not 127.0.0.1) and
// req.secure is true when the upstream TLS is terminated by the proxy.
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
// ---------------------------------------------------------------------------
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// ---------------------------------------------------------------------------
// Helmet v7 — security response headers
// CSP uses a per-request nonce injected into index.html so inline scripts
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
// ---------------------------------------------------------------------------
app.use((req, res, next) => {
// Generate a fresh nonce for every request
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
}
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false // not needed for this SPA
})(req, res, next);
});
// Permissions-Policy — disable powerful browser features not needed by the app
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
);
next();
});
// ---------------------------------------------------------------------------
// General API rate limiter — applies to all /api/* routes
// More specific limiters (e.g. login) apply on top of this.
// ---------------------------------------------------------------------------
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
// ---------------------------------------------------------------------------
// Body parsing & cookies
// ---------------------------------------------------------------------------
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// ---------------------------------------------------------------------------
// Health / readiness endpoints (no auth, no rate-limit)
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
/**
* @openapi
* /health:
* get:
* tags: [Health]
* summary: Health check
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
* security: []
* responses:
* '200':
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ok"
* uptime:
* type: number
* description: Server uptime in seconds
* example: 3600.5
* version:
* type: string
* description: sofarr version
* example: "1.6.0"
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
});
/**
* @openapi
* /ready:
* get:
* tags: [Health]
* summary: Readiness check
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
* security: []
* responses:
* '200':
* description: Server is ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ready"
* '503':
* description: Server not ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "not ready"
* reason:
* type: string
* example: "EMBY_URL not configured"
*/
app.get('/ready', (req, res) => {
// Confirm critical config is present
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// ---------------------------------------------------------------------------
// Swagger UI - publicly accessible API documentation
// ---------------------------------------------------------------------------
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
customSiteTitle: 'sofarr API Documentation',
customCss: '.swagger-ui .topbar { display: none }',
customJs: [
'/swagger-auth-banner.js'
],
swaggerOptions: {
url: '/api/swagger.json'
}
}));
// Serve the raw OpenAPI spec as JSON with dynamic server URL
app.get('/api/swagger.json', (req, res) => {
// Clone the spec to avoid modifying the original
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
// Replace the server URL with the current request's origin
if (specCopy.servers && specCopy.servers.length > 0) {
const protocol = req.protocol;
const host = req.get('host');
specCopy.servers[0].url = `${protocol}://${host}`;
}
res.json(specCopy);
});
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
// CSRF protection applies to all state-changing /api/* requests except
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
// ---------------------------------------------------------------------------
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// All routes below this point require CSRF validation on mutating methods
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// ---------------------------------------------------------------------------
// Global error handler — never leak stack traces to clients
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
// ---------------------------------------------------------------------------
// TLS / HTTPS support
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const crypto = require('crypto');
const ipaddr = require('ipaddr.js');
function getEmbyUrl() {
return process.env.EMBY_URL;
}
function isIpAllowed(clientIp, allowedSubnetsStr) {
if (!allowedSubnetsStr) return true;
try {
const clientIpParsed = ipaddr.parse(clientIp);
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const subnet of subnets) {
let rangeStr = subnet;
let bits = null;
if (subnet.includes('/')) {
const parts = subnet.split('/');
rangeStr = parts[0];
bits = parseInt(parts[1], 10);
}
const rangeIpParsed = ipaddr.parse(rangeStr);
if (bits === null) {
// Exact IP match
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
return true;
}
// Handle IPv4 mapped IPv6 address case
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
return true;
}
}
continue;
}
// Match with subnet bits
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
if (clientIpParsed.match(rangeIpParsed, bits)) {
return true;
}
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
// Handle IPv4 mapped IPv6 address case matching IPv4 range
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
return true;
}
}
}
} catch (err) {
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
}
return false;
}
async function logStreamAuth(req, res, next) {
// 1. Subnet IP Filtering (First Priority)
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
}
// 2. Webhook Secret Bypass (High Priority)
const secretHeader = req.headers['x-webhook-secret'];
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
if (configuredSecret && secretHeader === configuredSecret) {
return next();
}
// 3. Session Cookie
const signed = !!process.env.COOKIE_SECRET;
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (rawCookie && rawCookie !== false) {
try {
const u = JSON.parse(rawCookie);
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
req.user = u;
return next();
}
} catch {
// Ignore JSON parse errors, fallback to basic auth
}
}
// 4. Basic Authentication Fallback
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
try {
const credentialsBase64 = authHeader.substring(6);
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
const colonIdx = credentialsStr.indexOf(':');
if (colonIdx !== -1) {
const username = credentialsStr.substring(0, colonIdx).trim();
const password = credentialsStr.substring(colonIdx + 1);
if (username && password) {
const embyUrl = getEmbyUrl();
if (!embyUrl) {
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
return res.status(401).json({ error: 'Authentication service unavailable' });
}
// Authenticate with Emby using stable DeviceId derived from username
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
},
timeout: 5000
});
const authData = authResponse.data;
const userId = authData.User.Id || authData.User.id;
// Fetch detailed profile to verify administrator status
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
},
timeout: 5000
});
const user = userResponse.data;
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
if (isAdmin) {
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
req.user = { id: user.Id, name: user.Name, isAdmin: true };
return next();
} else {
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
}
}
}
} catch (err) {
console.error('[logStreamAuth] Emby authentication error:', err.message);
}
}
// 5. Unauthorized / Access Denied
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
return res.status(401).json({ error: 'Unauthorized' });
}
module.exports = logStreamAuth;
+234 -10
View File
@@ -12,13 +12,17 @@ info:
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
## Rate Limiting
- General API: 300 requests per 15 minutes per IP
- Login: 10 failed attempts per 15 minutes per IP
- Webhooks: 60 requests per minute per IP
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
- *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks.
- **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`.
- *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold.
- **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`).
- **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls.
## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.6.0
version: 1.7.36
contact:
name: sofarr
license:
@@ -42,9 +46,9 @@ tags:
- name: Webhook
description: Webhook receivers for Sonarr/Radarr
- name: Sonarr
description: Sonarr API proxy
description: Selective Sonarr API proxy (specific endpoints only)
- name: Radarr
description: Radarr API proxy
description: Selective Radarr API proxy (specific endpoints only)
- name: SABnzbd
description: SABnzbd API proxy
- name: Emby
@@ -172,6 +176,27 @@ components:
nullable: true
description: Tooltip text for Ombi icon ("Request" or "Search")
example: "Request"
arrLink:
type: string
nullable: true
format: uri
description: Sonarr/Radarr show/movie web UI link (admin-only)
example: "http://sonarr:8989/series/show-slug"
downloadPath:
type: string
nullable: true
description: Save path in download client (admin-only)
example: "/downloads/series/show-slug"
targetPath:
type: string
nullable: true
description: Target path in library (admin-only)
example: "/tv/show-slug"
arrInstanceKey:
type: string
nullable: true
description: Sonarr/Radarr instance API key (admin-only)
example: "api-key-here"
DashboardPayload:
type: object
@@ -276,7 +301,6 @@ components:
- arrQueueId
- arrType
- arrInstanceUrl
- arrContentId
- arrContentType
properties:
arrQueueId:
@@ -301,6 +325,16 @@ components:
type: integer
description: episodeId (Sonarr) or movieId (Radarr)
example: 456
arrContentIds:
type: array
items:
type: integer
description: Array of episodeIds for multi-episode packs (Sonarr)
example: [456, 457]
arrSeriesId:
type: integer
description: seriesId for fallback automatic series search (Sonarr)
example: 789
arrContentType:
type: string
enum: [episode, movie]
@@ -782,8 +816,15 @@ paths:
post:
tags: [Webhook]
summary: Sonarr webhook
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody:
required: true
content:
@@ -819,8 +860,15 @@ paths:
post:
tags: [Webhook]
summary: Radarr webhook
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody:
required: true
content:
@@ -856,8 +904,15 @@ paths:
post:
tags: [Webhook]
summary: Ombi webhook
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
security: []
parameters:
- name: secret
in: query
required: false
schema:
type: string
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
requestBody:
required: true
content:
@@ -1743,3 +1798,172 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/debug/status:
get:
tags: [Debug]
summary: Check if log streaming is enabled
description: Returns whether the log streaming feature is enabled at runtime. No authentication required.
security: []
responses:
'200':
description: Feature status returned successfully
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
example: true
/api/debug/server-logs:
get:
tags: [Debug]
summary: Stream server logs in real-time
description: |
Streams server-side standard output (stdout/stderr) logs via Server-Sent Events (SSE).
**Security Policies:**
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
security:
- CookieAuth: []
parameters:
- name: X-Webhook-Secret
in: header
required: false
schema:
type: string
description: Fast-track webhook secret bypass token
responses:
'200':
description: Event stream established
content:
text/event-stream:
schema:
type: string
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden (feature disabled or IP not in subnet allowlist)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/debug/client-logs:
get:
tags: [Debug]
summary: Stream client console logs in real-time
description: |
Streams client-side console logs via Server-Sent Events (SSE).
**Security Policies:**
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
security:
- CookieAuth: []
parameters:
- name: X-Webhook-Secret
in: header
required: false
schema:
type: string
description: Fast-track webhook secret bypass token
responses:
'200':
description: Event stream established
content:
text/event-stream:
schema:
type: string
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden (feature disabled or IP not in subnet allowlist)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
tags: [Debug]
summary: Ingest client console logs
description: |
Ingests a batch of client-side console logs into the server-side rolling clientLogBuffer.
**Security Policies:**
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
security:
- CookieAuth: []
parameters:
- name: X-Webhook-Secret
in: header
required: false
schema:
type: string
description: Fast-track webhook secret bypass token
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
required: [level, message]
properties:
timestamp:
type: string
format: date-time
level:
type: string
enum: [info, warn, error]
message:
type: string
responses:
'200':
description: Logs ingested successfully
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
count:
type: integer
'400':
description: Invalid JSON body
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden (feature disabled or IP not in subnet allowlist)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+91 -19
View File
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
const { canBlocklist } = require('../services/DownloadAssembler');
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
function buildMetadataMaps(snapshot) {
const seriesMap = new Map();
for (const r of snapshot.sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
seriesMap.set(r.seriesId, r.series);
}
}
for (const r of snapshot.sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
const existing = seriesMap.get(r.seriesId);
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
seriesMap.set(r.seriesId, r.series);
}
}
}
const moviesMap = new Map();
for (const r of snapshot.radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
moviesMap.set(r.movieId, r.movie);
}
}
for (const r of snapshot.radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
const existing = moviesMap.get(r.movieId);
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
moviesMap.set(r.movieId, r.movie);
}
}
}
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
@@ -192,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
ombiBaseUrl
});
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
res.json({
user: user.name,
isAdmin,
@@ -487,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
ombiBaseUrl
});
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(),
@@ -494,13 +528,29 @@ router.get('/stream', requireAuth, async (req, res) => {
type: c.getClientType()
}));
// Append orphaned synthetic client entry if orphaned downloads exist
const hasOrphaned = userDownloads.some(dl => dl && dl.isOrphaned);
if (hasOrphaned && !downloadClients.some(c => c.id === 'orphaned')) {
downloadClients.push({
id: 'orphaned',
name: 'Orphaned (unconfigured client)',
type: 'orphaned'
});
}
// Filter Ombi requests by user if not admin or if showAll is false
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
}
const ombiRequestsFiltered = {
movie: filteredOmbiMovieRequests,
@@ -676,27 +726,43 @@ router.get('/stream', requireAuth, async (req, res) => {
router.post('/blocklist-search', requireAuth, async (req, res) => {
try {
const user = req.user;
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
return res.status(400).json({ error: 'Missing required fields' });
}
if (arrType !== 'sonarr' && arrType !== 'radarr') {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
}
// Look up the download to verify permission
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
// Look up the queue record directly from the *arr cache.
// downloadClientRegistry.getAllDownloads() returns raw download-client data
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
// is only populated later by DownloadMatcher during the SSE build phase.
// Instead, we verify permission by finding the record in the Sonarr/Radarr
// queue cache where record.id is the numeric queue ID.
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
const queueData = cache.get(queueCacheKey) || { records: [] };
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
if (!download) {
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
if (!queueRecord) {
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
return res.status(403).json({ error: 'Download not found or permission denied' });
}
// Build a minimal download-like object for canBlocklist eligibility check.
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
const downloadForCheck = {
importIssues: importIssues || [],
arrQueueId: queueRecord.id,
arrType
};
// Check if user can blocklist this download
if (!canBlocklist(download, user.isAdmin)) {
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
}
@@ -724,8 +790,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
// Step 2: Trigger a new automatic search
let commandBody;
if (arrType === 'sonarr' && arrContentType === 'episode') {
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
} else if (arrType === 'radarr' && arrContentType === 'movie') {
if (arrContentId) {
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
} else if (arrSeriesId) {
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
}
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
}
@@ -737,7 +809,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
const { pollAllServices } = require('../utils/poller');
pollAllServices().catch(() => {});
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
res.json({ ok: true });
} catch (err) {
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
+143
View File
@@ -0,0 +1,143 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const logStreamAuth = require('../middleware/logStreamAuth');
const {
logEmitter,
logBuffer,
clientLogBuffer,
ingestClientLogs
} = require('../utils/logCapture');
// Public status check (no auth, no 403 block, returns standard config state)
router.get('/status', (req, res) => {
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
});
// Global toggle check
router.use((req, res, next) => {
if (process.env.ENABLE_LOG_STREAM !== 'true') {
return res.status(403).json({ error: 'Log streaming feature is disabled' });
}
next();
});
// Enforce subnet and authentication validations on all debug routes
router.use(logStreamAuth);
/**
* GET /api/debug/server-logs
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
*/
router.get('/server-logs', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// Send historical server logs buffer first
for (const line of logBuffer) {
res.write(`data: ${line}\n\n`);
}
// Gracefully close for integration testing
if (req.query.testClose === 'true') {
res.end();
return;
}
const sendLog = (line) => {
try {
res.write(`data: ${line}\n\n`);
} catch (err) {
console.error('[debugRoutes] Error sending server log line:', err.message);
}
};
logEmitter.on('server-log', sendLog);
// 25s heartbeat comment to prevent proxy timeouts
const heartbeat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
// Ignore
}
}, 25000);
req.on('close', () => {
clearInterval(heartbeat);
logEmitter.off('server-log', sendLog);
});
});
/**
* GET /api/debug/client-logs
* Exposes a real-time SSE stream of ingested client-side console logs.
*/
router.get('/client-logs', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// Send historical client logs buffer first
for (const line of clientLogBuffer) {
res.write(`data: ${line}\n\n`);
}
// Gracefully close for integration testing
if (req.query.testClose === 'true') {
res.end();
return;
}
const sendClientLog = (line) => {
try {
res.write(`data: ${line}\n\n`);
} catch (err) {
console.error('[debugRoutes] Error sending client log line:', err.message);
}
};
logEmitter.on('client-log', sendClientLog);
// 25s heartbeat comment to prevent proxy timeouts
const heartbeat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
// Ignore
}
}, 25000);
req.on('close', () => {
clearInterval(heartbeat);
logEmitter.off('client-log', sendClientLog);
});
});
/**
* POST /api/debug/client-logs
* Receives batches of frontend console logs to store in buffer and emit.
*/
router.post('/client-logs', (req, res) => {
const logs = req.body;
if (!Array.isArray(logs)) {
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
}
try {
ingestClientLogs(logs);
return res.status(200).json({ success: true, count: logs.length });
} catch (err) {
console.error('[debugRoutes] Ingestion failed:', err.message);
return res.status(500).json({ error: 'Internal server error during ingestion' });
}
});
module.exports = router;
+92 -23
View File
@@ -2,9 +2,9 @@
const express = require('express');
const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
const { applyRequestFilters } = require('../utils/ombiFilters');
const router = express.Router();
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
// initialize() is idempotent - cheap no-op if already initialized
await arrRetrieverRegistry.initialize();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
// Filter by user if not admin or if showAll is false
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
@@ -126,6 +126,11 @@ router.get('/requests', requireAuth, async (req, res) => {
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
];
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
await decorateRequestsWithArrLinks(allRequests, isAdmin);
}
// Parse query params
let types = req.query.type;
let statuses = req.query.status;
@@ -205,10 +210,10 @@ router.get('/requests', requireAuth, async (req, res) => {
*/
router.post('/webhook/enable', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookBaseUrl = getSofarrWebhookBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
if (!webhookBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
@@ -221,13 +226,31 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
// Call Ombi API to register webhook
const axios = require('axios');
// Get existing settings to retrieve the database ID
const currentRes = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
).catch(err => {
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
return { data: {} };
});
const currentConfig = currentRes.data || {};
const settingsId = currentConfig.id || 0;
const response = await axios.post(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
id: settingsId,
enabled: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
@@ -444,10 +467,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
*/
router.post('/webhook/test', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookBaseUrl = getSofarrWebhookBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
if (!webhookBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
@@ -460,25 +483,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
const webhookUrl = `${webhookBaseUrl}/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'
try {
await axios.post(webhookUrl, {
notificationType: 'RequestAvailable',
requestId: 0,
requestedUser: 'test',
title: 'Test Request',
type: 'Movie',
requestStatus: 'Pending'
}, {
headers: {
'X-Sofarr-Webhook-Secret': webhookSecret,
'Content-Type': 'application/json'
}
});
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
} catch (error) {
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
const port = process.env.PORT || 3001;
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
let useHttps = false;
if (tlsEnabled) {
const fs = require('fs');
const path = require('path');
const certsDir = path.join(__dirname, '../../certs');
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
try {
fs.readFileSync(tlsCertPath);
fs.readFileSync(tlsKeyPath);
useHttps = true;
} catch {
useHttps = false;
}
}
});
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: false
});
await axios.post(localUrl, {
notificationType: 'RequestAvailable',
requestId: 0,
requestedUser: 'test',
title: 'Test Request',
type: 'Movie',
requestStatus: 'Pending'
}, {
headers: {
'X-Sofarr-Webhook-Secret': webhookSecret,
'Content-Type': 'application/json'
},
httpsAgent: useHttps ? agent : undefined
});
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
}
res.json({ success: true });
} catch (error) {
+6 -6
View File
@@ -4,7 +4,7 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
// Helper to get first Radarr instance (for notification proxy routes)
function getFirstRadarrInstance() {
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookBaseUrl = getSofarrWebhookBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
if (!webhookBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
+6 -6
View File
@@ -4,7 +4,7 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
// Helper to get first Sonarr instance (for notification proxy routes)
function getFirstSonarrInstance() {
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookBaseUrl = getSofarrWebhookBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
if (!webhookBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
+23 -5
View File
@@ -4,9 +4,10 @@ const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
const downloadClientRegistry = require('../utils/downloadClients');
/**
* @openapi
@@ -121,6 +122,7 @@ router.get('/', requireAuth, async (req, res) => {
// 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')
@@ -128,15 +130,21 @@ router.get('/', requireAuth, async (req, res) => {
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
const ombiWebhookConfigured = ombiInstances.length > 0
? await checkOmbiWebhookConfigured(ombiInstances[0])
: false;
// Find Sonarr and Radarr metrics from instances
// 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;
}
}
@@ -156,8 +164,18 @@ router.get('/', requireAuth, async (req, res) => {
cache: cacheStats,
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
}
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
},
// Per-download-client health summary including any lastError captured
// since the last successful call. Lets the admin status panel surface
// transient failures (auth expiry, RPC blips, etc.) without log scraping.
downloadClients: downloadClientRegistry.getAllClients().map(c => ({
instanceId: c.getInstanceId(),
instanceName: c.name,
clientType: c.getClientType(),
lastError: typeof c.getLastError === 'function' ? c.getLastError() : null
}))
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
+140 -51
View File
@@ -5,6 +5,7 @@ const { logToFile } = require('../utils/logger');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { buildArrQueueCache } = require('../utils/arrQueueHelpers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
const { extractRequestedUser } = require('../utils/ombiHelpers');
const requireAuth = require('../middleware/requireAuth');
@@ -87,7 +88,7 @@ const VALID_EVENT_TYPES = new Set([
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
// Ombi notification types
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
@@ -106,9 +107,14 @@ function pruneReplayCache() {
// Prune the replay cache once per minute
setInterval(pruneReplayCache, 60 * 1000).unref();
function isReplay(eventType, instanceName, eventDate) {
function isReplay(eventType, instanceName, eventDate, contentId) {
if (!eventDate) return false;
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
// Content-aware replay key: incorporates downloadId / series.id / movie.id when
// available so that distinct events sharing the same `date` (e.g. multiple
// Grab events for episodes in a season pack fired in the same second) do not
// falsely collide. Falls back to the prior shape when contentId is absent
// (e.g. Test events) so existing behaviour is preserved.
const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`;
if (recentEvents.has(key)) return true;
recentEvents.set(key, Date.now());
return false;
@@ -135,6 +141,7 @@ const HISTORY_EVENTS = new Set([
// Ombi event types — all Ombi events refresh the requests cache
const OMBI_EVENTS = new Set([
'NewRequest',
'RequestAvailable',
'RequestApproved',
'RequestDeclined',
@@ -143,13 +150,13 @@ const OMBI_EVENTS = new Set([
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
* @param {Object} req - Express request object
* @returns {boolean} True if secret is valid, false otherwise
*/
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
@@ -157,7 +164,7 @@ function validateWebhookSecret(req) {
}
if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
return false;
}
@@ -179,7 +186,7 @@ function validateWebhookSecret(req) {
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
* @param {string} eventType - the eventType from the webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
async function processWebhookEvent(serviceType, eventType, payload = null) {
const affectsQueue = QUEUE_EVENTS.has(eventType);
const affectsHistory = HISTORY_EVENTS.has(eventType);
const affectsOmbi = OMBI_EVENTS.has(eventType);
@@ -201,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const sonarrQueues = queuesByType.sonarr || [];
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
}
@@ -231,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const radarrQueues = queuesByType.radarr || [];
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
}
@@ -258,7 +245,66 @@ async function processWebhookEvent(serviceType, eventType) {
const ombiInstances = getOmbiInstances();
if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
await new Promise(r => setTimeout(r, initialDelay));
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
let ombiRequests = { movie: [], tv: [] };
let foundAndValid = false;
const maxRetries = 3;
const retryDelayMs = 1500;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
if (attempt > 1) {
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
await new Promise(r => setTimeout(r, retryDelayMs));
}
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
if (!requestId) {
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
foundAndValid = true;
break;
}
// Search in movie or tv lists
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
// Also check both if mediaType not specified
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
if (targetReq) {
const user = extractRequestedUser(targetReq);
if (user) {
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
foundAndValid = true;
break;
} else {
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
}
} else {
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
}
}
if (!foundAndValid && requestId) {
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
// Try to log the raw target request if we found one
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
if (targetReq) {
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
} else {
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
}
}
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
}
@@ -306,13 +352,13 @@ function validatePayload(body) {
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
*
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Sonarr, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window
@@ -339,6 +385,13 @@ function validatePayload(body) {
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport
* security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody:
* required: true
* content:
@@ -413,11 +466,21 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
const { eventType, instanceName, eventDate } = validation;
const sonarrInstances = getSonarrInstances();
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName);
const inst = matchedInst || sonarrInstances[0];
if (!matchedInst && instanceName) {
logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
}
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
// Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.series?.id || null;
// Skip replay protection for Test events
if (eventType === "Test") {
logToFile(`[Webhook] Sonarr Test event received — skipping replay protection`);
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
return res.status(200).json({ received: true, duplicate: true });
}
@@ -453,13 +516,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
*
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Radarr, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window
@@ -486,6 +549,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport
* security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody:
* required: true
* content:
@@ -560,11 +630,21 @@ router.post('/radarr', webhookLimiter, (req, res) => {
const { eventType, instanceName, eventDate } = validation;
const radarrInstances = getRadarrInstances();
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName);
const inst = matchedInst || radarrInstances[0];
if (!matchedInst && instanceName) {
logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
}
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
// Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.movie?.id || null;
// Skip replay protection for Test events
if (eventType === "Test") {
logToFile(`[Webhook] Radarr Test event received — skipping replay protection`);
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
return res.status(200).json({ received: true, duplicate: true });
}
@@ -600,13 +680,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* 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`.
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Ombi, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
* - Payload validation (must be JSON object with notificationType, requestId)
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
* - Replay protection: rejects duplicate events within 5-minute window
@@ -624,11 +704,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
*
* **x-integration-notes:** Configure Ombi webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Application Token: OMBI_API_KEY
* security: []
* parameters:
* - name: secret
* in: query
* required: false
* schema:
* type: string
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
* requestBody:
* required: true
* content:
@@ -716,9 +802,12 @@ router.post('/ombi', webhookLimiter, (req, res) => {
return res.status(401).json({ error: 'Unauthorized' });
}
// Ombi uses notificationType instead of eventType
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
const eventType = notificationType || req.body.eventType;
// 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);
@@ -730,10 +819,10 @@ router.post('/ombi', webhookLimiter, (req, res) => {
// 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 || new Date().toISOString();
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
const contentId = requestId || null;
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
if (isReplay(eventType, instanceName, eventDate, contentId)) {
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
return res.status(200).json({ received: true, duplicate: true });
}
@@ -751,7 +840,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
}
// Background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('ombi', eventType).catch(err => {
processWebhookEvent('ombi', eventType, req.body).catch(err => {
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
});
+14
View File
@@ -72,6 +72,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
// Match all download sources
const userDownloads = [];
const seenDownloadKeys = new Set();
const matchedArrQueueIds = new Set();
if (sabnzbdQueue.data?.queue?.slots) {
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
@@ -80,6 +81,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
userDownloads.push(dl);
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
}
}
}
@@ -91,12 +93,24 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
userDownloads.push(dl);
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
}
}
}
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);
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
}
}
// Phase 2: Match orphaned records that have no active download client counterpart
const orphanedMatches = await DownloadMatcher.matchOrphanedArrRecords(matchedArrQueueIds, context);
for (const dl of orphanedMatches) {
const key = `${dl.type}:${dl.title}`;
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
+399 -380
View File
@@ -9,6 +9,140 @@
const { mapTorrentToDownload } = require('../utils/qbittorrent');
const TagMatcher = require('./TagMatcher');
const DownloadAssembler = require('./DownloadAssembler');
const { logToFile } = require('../utils/logger');
const logger = {
debug: (msg) => logToFile(`[DEBUG] ${msg}`)
};
/**
* Normalizes a title by lowercase conversion and replacing periods/underscores/dashes with spaces.
* @param {string} str - The title to normalize
* @returns {string} Normalized title
*/
function normalizeTitle(str) {
if (!str) return '';
return String(str)
.toLowerCase()
.replace(/\./g, ' ')
.replace(/[\-_]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Compares a download client item name with a *arr title by checking both raw
* and normalized (dots/dashes/underscores to spaces) forms bidirectionally.
* Only logs on title fallback matches (when isFallback=true) to keep logs clean.
*/
function titleMatches(clientName, arrTitle, { isFallback = true, caller = 'DownloadMatcher' } = {}) {
if (!clientName || !arrTitle) return false;
const a = clientName.toLowerCase();
const b = arrTitle.toLowerCase();
const aNorm = normalizeTitle(clientName);
const bNorm = normalizeTitle(arrTitle);
const matched = a.includes(b) || b.includes(a) ||
aNorm.includes(bNorm) || bNorm.includes(aNorm) ||
aNorm.includes(b) || b.includes(aNorm) ||
a.includes(bNorm) || bNorm.includes(a);
if (matched && isFallback) {
logger.debug(`[DownloadMatcher] Title fallback match in ${caller} after normalization: "${clientName}" <-> "${arrTitle}"`);
}
return matched;
}
/**
* All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options.
* Defaults exist only as a last-resort safety net.
*
* @example
* buildArrDownload(sonarrMatch, context, { client: 'sabnzbd', instanceId: 'sabnzbd-default', instanceName: 'SABnzbd' })
*/
function buildArrDownload(record, context, options = {}) {
const {
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap
} = context;
// Detect if sonarr or radarr record
const isSeries = record.seriesId !== undefined || options.arrType === 'sonarr';
const mediaMap = isSeries ? seriesMap : moviesMap;
const tagMap = isSeries ? sonarrTagMap : radarrTagMap;
const mediaId = isSeries ? record.seriesId : record.movieId;
const media = mediaMap.get(mediaId) || record.series || record.movie;
if (!media) return null;
// Tag-based user filtering
const allTags = TagMatcher.extractAllTags(media.tags, tagMap);
const matchedUserTag = TagMatcher.extractUserTag(media.tags, tagMap, username);
if (!showAll && !matchedUserTag) return null;
// Safer default progress of 0 for items that haven't started yet
const progress = options.progress !== undefined ? options.progress : 0;
const dlObj = {
type: isSeries ? 'series' : 'movie',
title: options.title || record.title || record.sourceTitle,
coverArt: DownloadAssembler.getCoverArt(media),
status: options.status || record.status || 'Unknown',
progress,
mb: options.mb !== undefined ? options.mb : (record.size ? Math.round(record.size / 1024 / 1024) : 0),
size: options.size !== undefined ? options.size : (record.size || 0),
completedAt: options.completedAt || record.completed_time || null,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
// Strict neutral defaults to avoid incorrect SABnzbd-centric data
client: options.client || 'orphaned',
instanceId: options.instanceId || 'orphaned',
instanceName: options.instanceName || 'Unknown',
...options.overrides
};
if (isSeries) {
dlObj.seriesName = media.title;
dlObj.episodes = options.episodes || [];
} else {
dlObj.movieName = media.title;
dlObj.movieInfo = record;
}
const issues = DownloadAssembler.getImportIssues(record);
if (issues) dlObj.importIssues = issues;
dlObj.arrQueueId = record.id;
dlObj.arrType = isSeries ? 'sonarr' : 'radarr';
dlObj.arrInstanceUrl = record._instanceUrl || null;
dlObj.arrContentId = record.episodeId || record.movieId || null;
dlObj.arrContentIds = record.episodeIds || null;
dlObj.arrSeriesId = record.seriesId || null;
dlObj.arrContentType = isSeries ? 'episode' : 'movie';
// Use correct blocklist determination
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
if (isAdmin) {
dlObj.downloadPath = options.downloadPath || null;
dlObj.targetPath = media.path || null;
dlObj.arrInstanceKey = record._instanceKey || null;
dlObj.arrLink = isSeries
? DownloadAssembler.getSonarrLink(media)
: DownloadAssembler.getRadarrLink(media);
}
addOmbiMatching(dlObj, media, context);
return dlObj;
}
/**
* Builds a Map of series metadata from Sonarr queue and history records.
@@ -90,19 +224,9 @@ async function matchSabSlots(slots, context) {
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
queueStatus,
queueSpeed,
queueKbpersec,
ombiRetriever,
ombiBaseUrl
queueKbpersec
} = context;
const matched = [];
@@ -113,9 +237,6 @@ async function matchSabSlots(slots, context) {
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;
@@ -132,149 +253,70 @@ async function matchSabSlots(slots, context) {
// 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)
);
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
});
}
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)
);
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
});
}
// 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)
);
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
});
}
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)
);
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
});
}
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;
// Progress calculation
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);
}
const commonOptions = {
title: nzbName,
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
size: Math.round(slot.mb * 1024 * 1024),
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd',
downloadPath: slot.storage || null,
overrides: {
mbmissing: slot.mbleft,
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft
}
};
if (sonarrMatch && sonarrMatch.seriesId) {
const dlObj = buildArrDownload(sonarrMatch, context, {
...commonOptions,
arrType: 'sonarr',
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords)
});
if (dlObj) 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);
}
}
const dlObj = buildArrDownload(radarrMatch, context, {
...commonOptions,
arrType: 'radarr'
});
if (dlObj) matched.push(dlObj);
}
}
return matched;
@@ -288,18 +330,10 @@ async function matchSabSlots(slots, context) {
*/
async function matchSabHistory(slots, context) {
const {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
ombiRetriever,
ombiBaseUrl
radarrQueueRecords,
radarrHistoryRecords
} = context;
const matched = [];
@@ -308,82 +342,67 @@ async function matchSabHistory(slots, context) {
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);
}
}
// Try to match by downloadId (nzo_id or slot ID) first (most reliable)
const sabDownloadId = slot.nzo_id || slot.id;
const matchesSabId = (r) => {
const dl = r && r.downloadId;
if (!dl || !sabDownloadId) return false;
return String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
};
let sonarrMatch = sabDownloadId ? sonarrHistoryRecords.find(matchesSabId) : null;
let radarrMatch = sabDownloadId ? radarrHistoryRecords.find(matchesSabId) : null;
// Dual-lookup: also try against active queue records (history slot may still be in *arr queue)
if (!sonarrMatch && sabDownloadId) {
sonarrMatch = sonarrQueueRecords.find(matchesSabId);
}
if (!radarrMatch && sabDownloadId) {
radarrMatch = radarrQueueRecords.find(matchesSabId);
}
// Fallback: Check by title matching
if (!sonarrMatch) {
sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
});
}
if (!radarrMatch) {
radarrMatch = radarrHistoryRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
});
}
const commonOptions = {
title: nzbName,
status: slot.status || 'Completed',
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd',
downloadPath: slot.storage || null
};
if (sonarrMatch && sonarrMatch.seriesId) {
const dlObj = buildArrDownload(sonarrMatch, context, {
...commonOptions,
arrType: 'sonarr',
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords)
});
if (dlObj) 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);
}
}
const dlObj = buildArrDownload(radarrMatch, context, {
...commonOptions,
arrType: 'radarr'
});
if (dlObj) matched.push(dlObj);
}
}
return matched;
@@ -400,17 +419,7 @@ async function matchTorrents(torrents, context) {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
ombiRetriever,
ombiBaseUrl
radarrHistoryRecords
} = context;
const matched = [];
@@ -419,169 +428,177 @@ async function matchTorrents(torrents, context) {
if (!torrentName) continue;
const torrentNameLower = torrentName.toLowerCase();
let matchedAny = false;
// Hash-first matching (Issue #65)
const torrentHash = torrent?.hash || torrent?.hashString || null;
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
const matchesByHash = (r) => {
const dl = r && r.downloadId;
if (!dl || !hashLower) return false;
return String(dl).toLowerCase() === hashLower;
};
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
// Fallback: Check by title matching
if (!sonarrMatch) {
sonarrMatch = sonarrQueueRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
});
}
if (!radarrMatch) {
radarrMatch = radarrQueueRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
});
}
// Fallback to history matching
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
if (!sonarrHistoryMatch) {
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
});
}
if (!radarrHistoryMatch) {
radarrHistoryMatch = radarrHistoryRecords.find(r => {
const rTitle = r.title || r.sourceTitle;
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
});
}
// Helper options for torrent mapping
const download = mapTorrentToDownload(torrent);
const progress = parseFloat(download.progress) || torrent.progress || 0;
const speed = download.rawSpeed || torrent.dlspeed || 0;
const commonOptions = {
title: torrentName,
status: download.status || torrent.status || 'Downloading',
progress: Math.round(progress),
mb: download.size ? Math.round(download.size / 1024 / 1024) : 0,
size: download.size || torrent.size || 0,
client: download.client || 'qbittorrent',
instanceId: torrent.instanceId || download.instanceId || 'qbittorrent-default',
instanceName: torrent.instanceName || download.instanceName || 'qBittorrent',
downloadPath: download.savePath || torrent.savePath || null,
overrides: {
id: download.hash || torrent.hash,
speed,
eta: torrent.eta,
seeds: torrent.seeds,
peers: torrent.peers,
availability: torrent.availability,
addedOn: torrent.addedOn,
qbittorrent: true
}
};
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 dlObj = buildArrDownload(sonarrMatch, context, {
...commonOptions,
arrType: 'sonarr',
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords)
});
if (dlObj) matched.push(dlObj);
}
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 dlObj = buildArrDownload(radarrMatch, context, {
...commonOptions,
arrType: 'radarr'
});
if (dlObj) matched.push(dlObj);
}
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 dlObj = buildArrDownload(sonarrHistoryMatch, context, {
...commonOptions,
arrType: 'sonarr',
progress: 100, // completed
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords)
});
if (dlObj) matched.push(dlObj);
}
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;
}
const dlObj = buildArrDownload(radarrHistoryMatch, context, {
...commonOptions,
arrType: 'radarr',
progress: 100 // completed
});
if (dlObj) matched.push(dlObj);
}
}
// Deduplicate by (arrType, arrQueueId) (Issue #65)
const seen = new Set();
const deduped = [];
for (const m of matched) {
const key = (m && m.arrType && m.arrQueueId != null)
? `${m.arrType}:${m.arrQueueId}`
: null;
if (key) {
if (seen.has(key)) continue;
seen.add(key);
}
deduped.push(m);
}
return deduped;
}
/**
* Matches orphaned *arr queue items that have no corresponding download client item
* but still reside in the active Sonarr/Radarr queue.
* @param {Set<number>} matchedArrQueueIds - Already matched queue record IDs to skip
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of orphaned download objects
*/
function matchOrphanedArrRecords(matchedArrQueueIds, context) {
const { sonarrQueueRecords, radarrQueueRecords } = context;
const matched = [];
// Deduplication Strategy:
// We initialize the processed Set with already-matched IDs compiled during Phase 1 matching.
// We also track newly processed IDs locally to handle situations where multiple duplicate queue
// records pointing to the same downloadId exist in Sonarr/Radarr.
const processedQueueIds = new Set(matchedArrQueueIds);
const processRecords = (records, arrType) => {
for (const record of records) {
if (processedQueueIds.has(record.id)) continue;
processedQueueIds.add(record.id);
// Safe progress arithmetic to prevent NaN or division-by-zero
const size = record.size || 0;
const sizeleft = record.sizeleft || 0;
const progress = size > 0 ? Math.round(100 - (sizeleft / size * 100)) : 0;
const status = record.trackedDownloadStatus || record.status || 'Unknown';
const dl = buildArrDownload(record, context, {
arrType,
status,
progress,
client: 'orphaned',
instanceId: 'orphaned',
instanceName: 'Orphaned (unconfigured client)',
overrides: { isOrphaned: true }
});
if (dl) {
logger.debug(`[DownloadMatcher] Found orphaned *arr queue item: ${dl.title} (arrQueueId=${record.id})`);
matched.push(dl);
}
}
};
processRecords(sonarrQueueRecords || [], 'sonarr');
processRecords(radarrQueueRecords || [], 'radarr');
}
return matched;
}
@@ -592,5 +609,7 @@ module.exports = {
addOmbiMatching,
matchSabSlots,
matchSabHistory,
matchTorrents
matchTorrents,
buildArrDownload,
matchOrphanedArrRecords
};
+19
View File
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
};
}
/**
* 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
};
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
//
// Shared helpers for assembling the cached *arr queue payload.
//
// Both the background poller (`server/utils/poller.js`) and the webhook
// processor (`server/routes/webhook.js`) build the `poll:sonarr-queue` and
// `poll:radarr-queue` cache entries from an array of per-instance queue
// responses. Historically the same `flatMap` block was duplicated across all
// four call sites (Sonarr + Radarr × poller + webhook) and had begun to drift.
//
// This module centralises that logic, adds defensive null-guards, and — for
// Sonarr only — annotates season-pack records (queue entries sharing a
// `downloadId`) with `isSeasonPack` and `episodeCount`. See Issue #61.
//
const { logToFile } = require('./logger');
/**
* Build the flattened, instance-tagged `records` array for the
* `poll:sonarr-queue` / `poll:radarr-queue` cache entry.
*
* @param {Array<{ instance: string, data: { records?: Array<object> } }>} queues
* Per-instance queue responses as returned by
* `arrRetrieverRegistry.getQueuesByType()` (or the equivalent batched
* retrieval in the poller).
* @param {Array<{ id: string, url: string, apiKey: string, name?: string }>} instances
* Configured instances; used to resolve `_instanceUrl` / `_instanceKey`.
* @param {'series'|'movie'} mediaKey
* Sonarr records embed a `series` object; Radarr records embed a `movie`
* object. The embedded object is annotated with `_instanceUrl` so that
* downstream link builders work.
* @returns {Array<object>} The flattened, annotated records array.
*/
function buildArrQueueCache(queues, instances, mediaKey) {
if (!Array.isArray(queues) || queues.length === 0) return [];
if (mediaKey !== 'series' && mediaKey !== 'movie') {
logToFile(`[arrQueueHelpers] Invalid mediaKey "${mediaKey}"; expected 'series' or 'movie'`);
return [];
}
const safeInstances = Array.isArray(instances) ? instances : [];
const out = [];
for (const q of queues) {
try {
if (!q || !q.data) continue;
const inst = safeInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
const records = Array.isArray(q.data.records) ? q.data.records : [];
for (const r of records) {
try {
if (!r) continue;
if (r[mediaKey]) {
r[mediaKey]._instanceUrl = url;
}
r._instanceUrl = url;
r._instanceKey = key;
out.push(r);
} catch (perRecordErr) {
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} record: ${perRecordErr.message}`);
}
}
} catch (perInstanceErr) {
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} queue payload: ${perInstanceErr.message}`);
}
}
// Sonarr-only: season pack annotation. Group by downloadId; entries that
// share a downloadId are episodes belonging to the same release (a season
// pack). Movies (mediaKey === 'movie') are single-record by nature.
if (mediaKey === 'series') {
try {
const groups = new Map();
for (const r of out) {
const dlId = r && r.downloadId;
if (!dlId) continue;
if (!groups.has(dlId)) groups.set(dlId, []);
groups.get(dlId).push(r);
}
for (const group of groups.values()) {
if (group.length > 1) {
for (const r of group) {
r.isSeasonPack = true;
r.episodeCount = group.length;
}
}
}
} catch (annotateErr) {
logToFile(`[arrQueueHelpers] Season-pack annotation failed: ${annotateErr.message}`);
}
}
return out;
}
module.exports = {
buildArrQueueCache
};
+7 -5
View File
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
/**
* 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() {
async getOmbiRequests(force = false) {
const ombiRetrievers = this.getOmbiRetrievers();
if (ombiRetrievers.length === 0) {
return { movie: [], tv: [] };
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
// Use the first Ombi retriever (single instance expected)
const retriever = ombiRetrievers[0];
try {
const movieRequests = await retriever.getMovieRequests();
const tvRequests = await retriever.getTvRequests();
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}`);
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
/**
* 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() {
return await this.getOmbiRequests();
async getOmbiRequestsByType(force = false) {
return await this.getOmbiRequests(force);
},
/**
+5
View File
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
return process.env.SOFARR_BASE_URL || '';
}
function getSofarrWebhookBaseUrl() {
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
@@ -140,6 +144,7 @@ module.exports = {
getRtorrentInstances,
getWebhookSecret,
getSofarrBaseUrl,
getSofarrWebhookBaseUrl,
parseInstances,
validateInstanceUrl
};
+6 -2
View File
@@ -239,7 +239,10 @@ class DownloadClientRegistry {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status
status,
// Surface the per-client lastError so admins can see transient
// failures (auth expiry, RPC blips, etc.) without scraping logs.
lastError: typeof client.getLastError === 'function' ? client.getLastError() : null
};
} catch (error) {
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
@@ -248,7 +251,8 @@ class DownloadClientRegistry {
instanceName: client.name,
clientType: client.getClientType(),
status: null,
error: error.message
error: error.message,
lastError: typeof client.getLastError === 'function' ? client.getLastError() : null
};
}
})
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const EventEmitter = require('events');
class LogEmitter extends EventEmitter {}
const logEmitter = new LogEmitter();
const logBuffer = [];
const clientLogBuffer = [];
const MAX_BUFFER_SIZE = 1000;
// ANSI escape code regular expression for stripping terminal colors
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
function stripAnsi(str) {
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
}
// Keep track of original stdout/stderr write functions
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
// Buffer to accumulate partial lines from stdout and stderr
let stdoutLineBuffer = '';
let stderrLineBuffer = '';
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
let str = '';
if (Buffer.isBuffer(data)) {
str = data.toString(encoding || 'utf8');
} else if (typeof data === 'string') {
str = data;
}
// Delegate writing to the original stream first
callback.call(this, data, encoding);
// Append new data to the accumulator
const accumulated = lineAccumulator.buffer + str;
const lines = accumulated.split(/\r?\n/);
// The last element is either empty (if str ended with \n) or a partial line
lineAccumulator.buffer = lines.pop();
for (const line of lines) {
const cleanLine = stripAnsi(line);
if (!cleanLine) continue;
// Prepend timestamp if not present (format: [ISO] Message)
const timestampedLine = cleanLine.startsWith('[')
? cleanLine
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
logBuffer.push(timestampedLine);
if (logBuffer.length > MAX_BUFFER_SIZE) {
logBuffer.shift();
}
logEmitter.emit('server-log', timestampedLine);
}
}
// Accumulator objects to allow updating string buffers by reference
const stdoutAccumulator = { buffer: '' };
const stderrAccumulator = { buffer: '' };
let isHooked = false;
function init() {
if (isHooked) return;
// Intercept stdout
process.stdout.write = function(data, encoding, callback) {
processStreamData.call(
process.stdout,
data,
encoding,
originalStdoutWrite,
'stdout',
stdoutAccumulator
);
if (typeof callback === 'function') callback();
return true;
};
// Intercept stderr
process.stderr.write = function(data, encoding, callback) {
processStreamData.call(
process.stderr,
data,
encoding,
originalStderrWrite,
'stderr',
stderrAccumulator
);
if (typeof callback === 'function') callback();
return true;
};
isHooked = true;
}
/**
* Ingests a list of client-side logs into the rolling clientLogBuffer.
* Each client log is expected to have structure: { timestamp, level, message }
*/
function ingestClientLogs(logs) {
if (!Array.isArray(logs)) return;
for (const log of logs) {
const timestamp = log.timestamp || new Date().toISOString();
const level = (log.level || 'info').toUpperCase();
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
clientLogBuffer.push(formattedLog);
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
clientLogBuffer.shift();
}
logEmitter.emit('client-log', formattedLog);
}
}
module.exports = {
init,
logEmitter,
logBuffer,
clientLogBuffer,
ingestClientLogs
};
+17
View File
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
if (request.denied) return 'denied';
if (request.approved) return 'approved';
if (request.requested) return 'pending';
// Ombi TV requests store status flags inside childRequests
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
for (const child of request.childRequests) {
if (child && child.available) return 'available';
}
for (const child of request.childRequests) {
if (child && child.denied) return 'denied';
}
for (const child of request.childRequests) {
if (child && child.approved) return 'approved';
}
for (const child of request.childRequests) {
if (child && child.requested) return 'pending';
}
}
return 'unknown';
}
+206 -11
View File
@@ -5,6 +5,8 @@
* not a string, so we need to extract the username from the object.
*/
const { logToFile } = require('./logger');
/**
* Extracts the username from an Ombi request object.
* Handles both the OmbiUser object format and legacy string format.
@@ -15,17 +17,57 @@
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 || '';
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
const userSource = request.requestedUser || request.RequestedUser ||
request.user || request.User ||
request.requestedBy || request.RequestedBy ||
request.ombiUser || request.OmbiUser ||
request.requestedByUser || request.RequestedByUser;
// If userSource is an object, extract key fields
if (userSource && typeof userSource === 'object') {
const username = userSource.alias || userSource.Alias ||
userSource.userAlias || userSource.UserAlias ||
userSource.userName || userSource.UserName ||
userSource.normalizedUserName || userSource.NormalizedUserName ||
userSource.displayName || userSource.DisplayName ||
userSource.email || userSource.Email;
if (username) return username;
}
// Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || '';
// If userSource is a string and not an empty object/array
if (userSource && typeof userSource === 'string') {
return userSource;
}
// Fallbacks on the request root level
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
request.requestedByUsername || request.RequestedByUsername ||
request.requester || request.Requester ||
request.requestedByEmail || request.RequestedByEmail;
if (rootFallback) return rootFallback;
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
if (Array.isArray(request.seasons)) {
for (const season of request.seasons) {
const seasonUser = extractRequestedUser(season);
if (seasonUser) return seasonUser;
}
}
if (Array.isArray(request.childRequests)) {
for (const child of request.childRequests) {
const childUser = extractRequestedUser(child);
if (childUser) return childUser;
}
}
// Add warning log when user extraction returns empty for non-empty requests
if (Object.keys(request).length > 0 && !request.notificationType) {
logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`);
}
return '';
}
function filterRequestsByUser(requests, username, showAll) {
@@ -38,7 +80,160 @@ function filterRequestsByUser(requests, username, showAll) {
});
}
async function decorateRequestsWithArrLinks(requests, isAdmin) {
if (!isAdmin || !Array.isArray(requests)) return;
const arrRetrieverRegistry = require('./arrRetrievers');
await arrRetrieverRegistry.initialize();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
const [sonarrData, radarrData] = await Promise.all([
Promise.all(sonarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/series`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, series: response.data || [] };
} catch {
return { instance: r, series: [] };
}
})),
Promise.all(radarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, movies: response.data || [] };
} catch {
return { instance: r, movies: [] };
}
}))
]);
requests.forEach(req => {
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
// Fallback to checking for TV specific IDs.
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
if (isTv) {
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
if (!tvdbId) return;
for (const instData of sonarrData) {
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
req.arrType = 'sonarr';
break;
}
}
} else {
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
if (!tmdbId) return;
for (const instData of radarrData) {
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
req.arrType = 'radarr';
break;
}
}
}
});
}
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
if (!isAdmin || !Array.isArray(downloads)) return;
const arrRetrieverRegistry = require('./arrRetrievers');
await arrRetrieverRegistry.initialize();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
const [sonarrData, radarrData] = await Promise.all([
Promise.all(sonarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/series`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, series: response.data || [] };
} catch {
return { instance: r, series: [] };
}
})),
Promise.all(radarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, movies: response.data || [] };
} catch {
return { instance: r, movies: [] };
}
}))
]);
downloads.forEach(dl => {
// Determine if it's TV (series) or Movie
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
if (isTv) {
// Look for a match in Sonarr instances
for (const instData of sonarrData) {
const match = instData.series.find(s => {
if (!s) return false;
// Match by database series ID if the instance matches
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
return true;
}
// Fallback to seriesName matching
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
return true;
}
return false;
});
if (match && match.titleSlug) {
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
dl.arrType = 'sonarr';
break;
}
}
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
// Look for a match in Radarr instances
for (const instData of radarrData) {
const match = instData.movies.find(m => {
if (!m) return false;
// Match by database movie ID if instance matches
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
return true;
}
// Fallback to movieName matching
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
return true;
}
return false;
});
if (match && match.titleSlug) {
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
dl.arrType = 'radarr';
break;
}
}
}
});
}
module.exports = {
extractRequestedUser,
filterRequestsByUser
filterRequestsByUser,
decorateRequestsWithArrLinks,
decorateDownloadsWithArrLinks
};
+4 -22
View File
@@ -3,11 +3,13 @@ const axios = require('axios');
const cache = require('./cache');
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
const arrRetrieverRegistry = require('./arrRetrievers');
const { buildArrQueueCache } = require('./arrQueueHelpers');
const {
getSonarrInstances,
getRadarrInstances,
getOmbiInstances
} = require('./config');
const { logToFile } = require('./logger');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
@@ -236,17 +238,7 @@ async function pollAllServices() {
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
@@ -264,17 +256,7 @@ async function pollAllServices() {
// Radarr
if (shouldPollRadarr) {
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
+136
View File
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
expect(result.childNodes.length).toBe(0);
});
});
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
import { state } from '../../../client/src/state.js';
describe('createDownloadCard rendering details', () => {
let originalState;
beforeEach(() => {
originalState = { ...state };
});
afterEach(() => {
// Reset global state
Object.assign(state, originalState);
});
describe('createClientLogo and fallbacks', () => {
it('renders client logo img tag when client is configured', () => {
const dl = {
title: 'Test Download',
type: 'series',
client: 'qbittorrent',
instanceName: 'Qbit Main'
};
const card = createDownloadCard(dl);
const wrapper = card.querySelector('.download-client-logo-wrapper');
expect(wrapper).toBeTruthy();
const img = wrapper.querySelector('img.download-client-logo');
expect(img).toBeTruthy();
expect(img.src).toContain('/images/clients/qbittorrent.svg');
expect(img.alt).toBe('Qbit Main icon');
});
it('falls back to character avatar text on img load error', () => {
const dl = {
title: 'Test Download',
type: 'series',
client: 'transmission'
};
const card = createDownloadCard(dl);
const wrapper = card.querySelector('.download-client-logo-wrapper');
const img = wrapper.querySelector('img');
// Trigger the onerror event programmatically to simulate missing/broken SVG
img.onerror();
expect(wrapper.classList.contains('fallback')).toBe(true);
expect(wrapper.textContent).toBe('T');
});
});
describe('createServiceIcons deep-linking', () => {
it('renders Ombi icon link for all users when ombiLink exists', () => {
state.isAdmin = false; // Non-admin should still see Ombi icon
const dl = {
title: 'Mandalorian S01E01',
type: 'series',
seriesName: 'The Mandalorian',
ombiLink: 'https://ombi.test/request/42',
ombiTooltip: 'View on Ombi'
};
const card = createDownloadCard(dl);
const ombiLinkEl = card.querySelector('.download-series a');
expect(ombiLinkEl).toBeTruthy();
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
expect(img).toBeTruthy();
expect(img.title).toBe('View on Ombi');
});
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
state.isAdmin = true; // Admin required for Sonarr link
const dl = {
title: 'Mandalorian S01E01',
type: 'series',
seriesName: 'The Mandalorian',
arrType: 'sonarr',
arrLink: 'https://sonarr.test/series/the-mandalorian'
};
const card = createDownloadCard(dl);
const arrLinkEl = card.querySelector('.download-series a');
expect(arrLinkEl).toBeTruthy();
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
expect(img).toBeTruthy();
expect(img.title).toBe('Sonarr');
});
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
state.isAdmin = true; // Admin required for Radarr link
const dl = {
title: 'Blade Runner 2049',
type: 'movie',
movieName: 'Blade Runner 2049',
arrType: 'radarr',
arrLink: 'https://radarr.test/movie/blade-runner-2049'
};
const card = createDownloadCard(dl);
const arrLinkEl = card.querySelector('.download-movie a');
expect(arrLinkEl).toBeTruthy();
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
const img = arrLinkEl.querySelector('img.service-icon.radarr');
expect(img).toBeTruthy();
expect(img.title).toBe('Radarr');
});
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
state.isAdmin = false; // Non-admin
const dl = {
title: 'Mandalorian S01E01',
type: 'series',
seriesName: 'The Mandalorian',
arrType: 'sonarr',
arrLink: 'https://sonarr.test/series/the-mandalorian'
};
const card = createDownloadCard(dl);
const arrLinkEl = card.querySelector('.download-series a');
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
});
});
});
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/requests.js
*
* Verifies requests dashboard rendering, tooltips, dates, and deep links.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderRequests } from '../../../client/src/ui/requests.js';
import { state } from '../../../client/src/state.js';
vi.mock('../../../client/src/state.js', () => {
return {
state: {
ombiRequests: { movie: [], tv: [] },
selectedRequestTypes: ['movie', 'tv'],
selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'],
requestSortMode: 'requestedDate_desc',
requestSearchQuery: '',
ombiBaseUrl: 'https://ombi.test',
isAdmin: false
}
};
});
describe('requests rendering', () => {
let requestsList, noRequests;
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div id="requests-list"></div>
<div id="no-requests" style="display: none;"><p></p></div>
`;
requestsList = document.getElementById('requests-list');
noRequests = document.getElementById('no-requests');
state.ombiRequests = { movie: [], tv: [] };
state.isAdmin = false;
state.ombiBaseUrl = 'https://ombi.test';
});
it('renders "No requests found." when request arrays are empty', () => {
renderRequests();
expect(requestsList.childNodes.length).toBe(0);
expect(noRequests.style.display).toBe('block');
expect(noRequests.querySelector('p').textContent).toBe('No requests found.');
});
it('renders request card with correctly formatted date, media type, and requester', () => {
state.ombiRequests = {
movie: [
{
id: 101,
title: 'Movie Test',
year: '2026',
requestedUser: { alias: 'john_doe' },
requestedDate: '2026-05-27T10:15:30.000Z',
quality: '1080p',
theMovieDbId: 555,
requested: true
}
],
tv: []
};
renderRequests();
expect(requestsList.childNodes.length).toBe(1);
const card = requestsList.childNodes[0];
expect(card.querySelector('.request-title').textContent).toBe('Movie Test');
expect(card.querySelector('.request-year').textContent).toBe('2026');
expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe');
// Check formatted date
const dateEl = card.querySelector('.request-date');
expect(dateEl).toBeTruthy();
expect(dateEl.textContent).toContain('Date: 2026-05-27');
// Check view in Ombi link
const ombiLink = card.querySelector('.ombi-link');
expect(ombiLink).toBeTruthy();
expect(ombiLink.href).toBe('https://ombi.test/details/movie/555');
});
it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => {
state.ombiRequests = {
movie: [],
tv: [
{
id: 201,
title: 'TV Test No User',
requestedDate: '2026-05-27T12:00:00.000Z',
requested: true
}
]
};
renderRequests();
expect(requestsList.childNodes.length).toBe(1);
const card = requestsList.childNodes[0];
const userEl = card.querySelector('.request-user');
expect(userEl).toBeTruthy();
expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)');
expect(userEl.title).toBe('No user information received from Ombi');
expect(userEl.style.textDecoration).toBe('underline dotted');
});
it('does NOT render Sonarr/Radarr deep links for non-admin users', () => {
state.isAdmin = false;
state.ombiRequests = {
movie: [
{
id: 101,
title: 'Movie Test',
theMovieDbId: 555,
arrLink: 'http://radarr:7878/movie/slug',
arrType: 'radarr',
requested: true
}
],
tv: []
};
renderRequests();
const card = requestsList.childNodes[0];
expect(card.querySelector('.radarr-link')).toBeNull();
});
it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => {
state.isAdmin = true;
state.ombiRequests = {
movie: [
{
id: 101,
title: 'Movie Test',
theMovieDbId: 555,
arrLink: 'http://radarr:7878/movie/slug',
arrType: 'radarr',
requested: true
}
],
tv: [
{
id: 202,
title: 'TV Show Test',
theMovieDbId: 666,
arrLink: 'http://sonarr:8989/series/slug',
arrType: 'sonarr',
requested: true
}
]
};
renderRequests();
expect(requestsList.childNodes.length).toBe(2);
// Check Radarr link
const movieCard = requestsList.childNodes[0];
const radarrLink = movieCard.querySelector('.radarr-link');
expect(radarrLink).toBeTruthy();
expect(radarrLink.href).toBe('http://radarr:7878/movie/slug');
expect(radarrLink.title).toBe('View in Radarr');
// Check Sonarr link
const tvCard = requestsList.childNodes[1];
const sonarrLink = tvCard.querySelector('.sonarr-link');
expect(sonarrLink).toBeTruthy();
expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug');
expect(sonarrLink.title).toBe('View in Sonarr');
});
});
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/theme.js
*
* Verifies DOM actions for theme switcher button clicks, attributes, and storage calls.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js';
import * as storage from '../../../client/src/utils/storage.js';
vi.mock('../../../client/src/utils/storage.js', () => {
let store = {};
return {
getTheme: vi.fn(() => store.theme || 'light'),
saveTheme: vi.fn((theme) => { store.theme = theme; })
};
});
describe('theme switcher', () => {
let lightBtn, darkBtn, monoBtn;
beforeEach(() => {
vi.clearAllMocks();
document.documentElement.removeAttribute('data-theme');
// Create mock theme buttons
document.body.innerHTML = `
<div class="theme-switcher">
<button class="theme-btn" data-theme="light">Light</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button>
</div>
`;
lightBtn = document.querySelector('[data-theme="light"]');
darkBtn = document.querySelector('[data-theme="dark"]');
monoBtn = document.querySelector('[data-theme="mono"]');
});
it('initThemeSwitcher sets active class based on saved theme on load', () => {
vi.spyOn(storage, 'getTheme').mockReturnValue('dark');
initThemeSwitcher();
expect(storage.getTheme).toHaveBeenCalled();
expect(darkBtn.classList.contains('active')).toBe(true);
expect(lightBtn.classList.contains('active')).toBe(false);
expect(monoBtn.classList.contains('active')).toBe(false);
});
it('initThemeSwitcher defaults to light theme if no theme is saved', () => {
vi.spyOn(storage, 'getTheme').mockReturnValue(null);
initThemeSwitcher();
expect(lightBtn.classList.contains('active')).toBe(true);
expect(darkBtn.classList.contains('active')).toBe(false);
});
it('clicking theme button switches the document theme and persists choice', () => {
initThemeSwitcher();
// Initial active button should be light
expect(lightBtn.classList.contains('active')).toBe(true);
// Click Dark
darkBtn.click();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
expect(storage.saveTheme).toHaveBeenCalledWith('dark');
expect(darkBtn.classList.contains('active')).toBe(true);
expect(lightBtn.classList.contains('active')).toBe(false);
// Click Mono
monoBtn.click();
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
expect(monoBtn.classList.contains('active')).toBe(true);
expect(darkBtn.classList.contains('active')).toBe(false);
});
it('setTheme directly sets document attribute and updates button classes if present', () => {
initThemeSwitcher(); // binds buttons
setTheme('mono');
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
expect(monoBtn.classList.contains('active')).toBe(true);
expect(lightBtn.classList.contains('active')).toBe(false);
});
});
@@ -0,0 +1,110 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/utils/clientLogCapture.js
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { initClientLogCapture } from '../../../client/src/utils/clientLogCapture.js';
describe('clientLogCapture', () => {
let fetchMock;
let originalConsoleLog;
let originalConsoleWarn;
let originalConsoleError;
beforeEach(() => {
vi.useFakeTimers();
// Preserve original console methods
originalConsoleLog = console.log;
originalConsoleWarn = console.warn;
originalConsoleError = console.error;
// Reset console methods to standard ones
console.log = vi.fn();
console.warn = vi.fn();
console.error = vi.fn();
// Mock window fetch
fetchMock = vi.fn();
global.window.fetch = fetchMock;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
// Restore original console methods
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
});
it('exits early and does not intercept console if status returns disabled', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ enabled: false })
});
await initClientLogCapture();
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
// Console calls should NOT be queued or overridden (i.e. they should just run vi.fn mocks)
console.log('Test message');
expect(console.log).toHaveBeenCalledWith('Test message');
expect(fetchMock).not.toHaveBeenCalledWith('/api/debug/client-logs', expect.any(Object));
});
it('hooks console and flushes logs periodically when status returns enabled', async () => {
fetchMock.mockImplementation((url, options) => {
if (url === '/api/debug/status') {
return Promise.resolve({
ok: true,
json: async () => ({ enabled: true })
});
}
if (url === '/api/debug/client-logs') {
return Promise.resolve({
ok: true,
json: async () => ({ success: true })
});
}
});
await initClientLogCapture();
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
// Trigger console logs
console.log('Booting app', { config: 'loaded' });
console.warn('Deprecated api call');
console.error('Failed request', new Error('timeout'));
// Move timers forward to trigger flush interval (2000ms)
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledWith('/api/debug/client-logs', expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}));
const lastCall = fetchMock.mock.calls.find(call => call[0] === '/api/debug/client-logs');
expect(lastCall).toBeDefined();
const loggedEntries = JSON.parse(lastCall[1].body);
expect(loggedEntries).toHaveLength(4); // Includes the interceptor boot message + 3 logs
expect(loggedEntries[1].level).toBe('info');
expect(loggedEntries[1].message).toContain('Booting app {"config":"loaded"}');
expect(loggedEntries[2].level).toBe('warn');
expect(loggedEntries[2].message).toContain('Deprecated api call');
expect(loggedEntries[3].level).toBe('error');
expect(loggedEntries[3].message).toContain('Failed request');
});
});
+239 -49
View File
@@ -225,7 +225,7 @@ function invalidatePollCache() {
'poll:sab-queue', 'poll:sab-history',
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
'poll:qbittorrent'
'poll:qbittorrent', 'poll:ombi-requests'
];
for (const k of keys) cache.invalidate(k);
}
@@ -260,19 +260,10 @@ async function csrfHeaders(app) {
// Environment
// ---------------------------------------------------------------------------
beforeAll(() => {
beforeEach(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
beforeEach(() => {
seedEmptyCache();
});
@@ -280,6 +271,9 @@ afterEach(() => {
nock.cleanAll();
invalidatePollCache();
cache.invalidate('emby:users');
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
// ---------------------------------------------------------------------------
@@ -349,6 +343,7 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.arrQueueId).toBe(1002);
expect(dl.arrType).toBe('sonarr');
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
expect(dl.downloadPath).toBeDefined();
});
@@ -562,6 +557,47 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.canBlocklist).toBe(true);
});
});
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
it('decorates active series downloads with Sonarr links for administrator', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Seed cache: queue record exists and matches SABnzbd slot
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
nock(SONARR_BASE)
.get('/api/v3/series')
.reply(200, [
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
]);
// Mock Radarr /api/v3/movie response
nock(RADARR_BASE)
.get('/api/v3/movie')
.reply(200, []);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const downloads = res.body.downloads;
const dl = downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
expect(dl.arrType).toBe('sonarr');
});
});
});
// ---------------------------------------------------------------------------
@@ -749,12 +785,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
}] }, CACHE_TTL);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
@@ -763,18 +801,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission denied/i);
mockGetAllDownloads.mockRestore();
});
it('returns 403 for non-admin when download not found in active downloads', async () => {
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return empty array (download not found)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
@@ -782,19 +816,21 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/download not found/i);
mockGetAllDownloads.mockRestore();
});
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
]);
// Seed cache: queue record with import issues qualifies non-admin for blocklist
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Import error 1'] }]
}] }, CACHE_TTL);
// Mock Sonarr DELETE and command endpoints
nock(SONARR_BASE)
@@ -812,7 +848,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 400 when required fields are missing', async () => {
@@ -843,11 +878,8 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -864,18 +896,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
]);
// Seed the Radarr queue cache so the permission lookup finds the record
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/2001')
@@ -892,18 +920,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -916,7 +940,90 @@ describe('POST /api/dashboard/blocklist-search', () => {
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
mockGetAllDownloads.mockRestore();
});
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
// Without String() casting the === comparison fails and returns 403.
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed Radarr queue with a numeric id (as Radarr API returns it)
cache.set('poll:radarr-queue', { records: [{
id: 9050001,
title: 'Project.Hail.Mary.2026.2160p',
movieId: 77,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'rk'
}] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/9050001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(RADARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
@@ -1021,5 +1128,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
expect(data.ombiRequests.movie).toHaveLength(2);
expect(data.ombiRequests.tv).toHaveLength(2);
});
it('verifies SSE payload structure contract against the frontend schema', async () => {
const { cookies } = await loginAs(appInstance);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
// Payload Contract Validation
expect(data).toHaveProperty('user');
expect(data).toHaveProperty('isAdmin');
expect(data).toHaveProperty('downloads');
expect(data).toHaveProperty('downloadClients');
expect(data).toHaveProperty('ombiRequests');
expect(data).toHaveProperty('ombiBaseUrl');
expect(Array.isArray(data.downloads)).toBe(true);
expect(Array.isArray(data.downloadClients)).toBe(true);
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
});
it('sends heartbeat comment over active stream and cleans up on close', async () => {
vi.useFakeTimers();
// 1. Get the route handler from the dashboard router stack
const dashboardRouter = require('../../server/routes/dashboard.js');
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
// Get the final handler (after requireAuth middleware)
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
// 2. Setup mock req and res
const mockUser = { name: 'Alice', isAdmin: false };
const reqOnCallbacks = {};
const mockReq = {
user: mockUser,
query: { showAll: 'false', testClose: 'false' },
on: vi.fn((event, cb) => {
reqOnCallbacks[event] = cb;
})
};
const resWrites = [];
const mockRes = {
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn((data) => {
resWrites.push(data);
}),
end: vi.fn()
};
// 3. Call the handler
await streamHandler(mockReq, mockRes);
// Initial payload should be written
expect(resWrites.length).toBeGreaterThan(0);
expect(resWrites[0]).toContain('data:');
// 4. Advance time by 25s to trigger the heartbeat setInterval
vi.advanceTimersByTime(25000);
// Check that heartbeat was written
expect(resWrites).toContain(': heartbeat\n\n');
// 5. Simulate client disconnect by triggering the 'close' event callback
expect(reqOnCallbacks['close']).toBeDefined();
reqOnCallbacks['close']();
// Check that advancing time again does NOT write another heartbeat
const beforeLength = resWrites.length;
vi.advanceTimersByTime(25000);
expect(resWrites.length).toBe(beforeLength); // No new writes!
vi.useRealTimers();
});
});
+213
View File
@@ -0,0 +1,213 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import nock from 'nock';
import { createApp } from '../../server/app.js';
const EMBY_BASE = 'https://emby.test';
describe('Debug Logs API Integration', () => {
beforeAll(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret-xyz';
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
delete process.env.ENABLE_LOG_STREAM;
delete process.env.LOG_ALLOW_SUBNETS;
delete process.env.TRUST_PROXY;
});
afterEach(() => {
nock.cleanAll();
});
describe('GET /api/debug/status', () => {
it('returns enabled: false when ENABLE_LOG_STREAM is not true', async () => {
process.env.ENABLE_LOG_STREAM = 'false';
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/debug/status');
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(false);
});
it('returns enabled: true when ENABLE_LOG_STREAM is true', async () => {
process.env.ENABLE_LOG_STREAM = 'true';
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/debug/status');
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
});
describe('Global toggle checking', () => {
it('returns 403 Forbidden on server logs GET when feature is disabled', async () => {
process.env.ENABLE_LOG_STREAM = 'false';
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/debug/server-logs');
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
it('returns 403 Forbidden on client logs GET when feature is disabled', async () => {
process.env.ENABLE_LOG_STREAM = 'false';
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/debug/client-logs');
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
it('returns 403 Forbidden on client logs POST when feature is disabled', async () => {
process.env.ENABLE_LOG_STREAM = 'false';
const app = createApp({ skipRateLimits: true });
const res = await request(app).post('/api/debug/client-logs').send([]);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/disabled/i);
});
});
describe('Subnet CIDR validation', () => {
beforeAll(() => {
process.env.ENABLE_LOG_STREAM = 'true';
process.env.LOG_ALLOW_SUBNETS = '127.0.0.1/32,192.168.1.0/24';
process.env.TRUST_PROXY = '1';
});
it('returns 403 Forbidden if client IP is not in subnet allowlist', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app)
.get('/api/debug/server-logs')
.set('X-Forwarded-For', '10.0.0.50');
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/Access denied from IP/i);
});
it('bypasses subnet check and hits auth validation if client IP is allowed', async () => {
const app = createApp({ skipRateLimits: true });
// In subnet allowlist but missing credentials -> returns 401 instead of 403!
const res = await request(app)
.get('/api/debug/server-logs')
.set('X-Forwarded-For', '192.168.1.150');
expect(res.status).toBe(401);
});
afterAll(() => {
delete process.env.LOG_ALLOW_SUBNETS;
delete process.env.TRUST_PROXY;
});
});
describe('Authentication and Bypass policies', () => {
beforeAll(() => {
process.env.ENABLE_LOG_STREAM = 'true';
});
it('returns 401 Unauthorized when all auth options are missing', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/debug/server-logs');
expect(res.status).toBe(401);
expect(res.headers['www-authenticate']).toContain('Basic realm=');
});
it('allows access via X-Webhook-Secret header bypass', async () => {
const app = createApp({ skipRateLimits: true });
// X-Webhook-Secret bypass avoids Emby login entirely (returns 200 SSE stream)
const res = await request(app)
.get('/api/debug/server-logs?testClose=true')
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/event-stream');
});
it('allows access via Basic Authentication with valid Emby administrator credentials', async () => {
const app = createApp({ skipRateLimits: true });
// Mock Emby login
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, {
AccessToken: 'admin-emby-tok',
User: { Id: 'admin-user-id', Name: 'embyadmin' }
});
// Mock Emby profile fetch verifying IsAdministrator is true
nock(EMBY_BASE)
.get('/Users/admin-user-id')
.reply(200, {
Id: 'admin-user-id',
Name: 'embyadmin',
Policy: { IsAdministrator: true }
});
const res = await request(app)
.get('/api/debug/server-logs?testClose=true')
.auth('embyadmin', 'password123');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/event-stream');
});
it('denies access via Basic Authentication if user is not an administrator', async () => {
const app = createApp({ skipRateLimits: true });
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, {
AccessToken: 'user-emby-tok',
User: { Id: 'regular-user-id', Name: 'embyuser' }
});
nock(EMBY_BASE)
.get('/Users/regular-user-id')
.reply(200, {
Id: 'regular-user-id',
Name: 'embyuser',
Policy: { IsAdministrator: false }
});
const res = await request(app)
.get('/api/debug/server-logs')
.auth('embyuser', 'password123');
expect(res.status).toBe(401);
});
});
describe('Client logs ingestion and streaming', () => {
beforeAll(() => {
process.env.ENABLE_LOG_STREAM = 'true';
});
it('returns 400 Bad Request on client logs POST if body is not an array', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app)
.post('/api/debug/client-logs')
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
.send({ message: 'not an array' });
expect(res.status).toBe(400);
});
it('ingests client logs array and streams them over client logs GET SSE', async () => {
const app = createApp({ skipRateLimits: true });
// Ingest client logs
const postRes = await request(app)
.post('/api/debug/client-logs')
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
.send([
{ timestamp: new Date().toISOString(), level: 'info', message: 'Hello from client' }
]);
expect(postRes.status).toBe(200);
expect(postRes.body.count).toBe(1);
// Verify log streams successfully via GET
const getRes = await request(app)
.get('/api/debug/client-logs?testClose=true')
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
expect(getRes.status).toBe(200);
expect(getRes.headers['content-type']).toContain('text/event-stream');
});
});
});
@@ -0,0 +1,204 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
//
// Integration tests for the torrent matcher's hash-first matching and
// arrQueueId deduplication paths (Issue #65). These exercise `matchTorrents`
// end-to-end against minimal but realistic queue/history record fixtures.
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const DownloadMatcher = require('../../server/services/DownloadMatcher');
// Build a minimal context. `showAll: true` bypasses per-user tag filtering so
// these tests can assert matching behaviour without setting up the Emby user
// tag plumbing.
function makeContext({
sonarrQueueRecords = [],
sonarrHistoryRecords = [],
radarrQueueRecords = [],
radarrHistoryRecords = []
} = {}) {
const seriesMap = new Map();
const moviesMap = new Map();
for (const r of sonarrQueueRecords.concat(sonarrHistoryRecords)) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
}
for (const r of radarrQueueRecords.concat(radarrHistoryRecords)) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
}
return {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
// null tagMaps so extractAllTags uses the object `label` shape from the
// Sonarr fixture (series.tags = [{ label: '...' }]). An empty Map is
// truthy and would cause every id-lookup to return undefined.
sonarrTagMap: null,
radarrTagMap: null,
username: 'tester',
isAdmin: false,
// showAll bypasses per-user tag filtering — we only need it to be truthy
// *together* with non-empty allTags. We seed series/movie tags as non-empty
// strings (Sonarr tag shape) so `extractAllTags` yields entries.
showAll: true,
embyUserMap: new Map(),
ombiBaseUrl: null
};
}
const seriesShowA = {
id: 100,
title: 'Show A',
tags: [{ label: 'tester' }],
images: []
};
const movieFilmB = {
id: 200,
title: 'Film B',
tags: [{ label: 'tester' }],
images: []
};
describe('matchTorrents — hash-first matching (#65)', () => {
it('matches a torrent to a Sonarr queue record by hash even when the title differs', async () => {
const hash = 'ABC123HASHsonarr';
const context = makeContext({
sonarrQueueRecords: [
{
id: 9001,
// Title intentionally bears no resemblance to the torrent name to
// prove the match is via hash (downloadId), not title fallback.
title: 'totally.unrelated.queue.record',
sourceTitle: '',
seriesId: 100,
series: seriesShowA,
downloadId: hash,
episodeId: 555
}
]
});
const torrents = [
{
hash,
name: 'Show.A.S01.2160p.WEB.x265-RELEASE',
progress: 0.5,
dlspeed: 1000
}
];
const out = await DownloadMatcher.matchTorrents(torrents, context);
expect(out).toHaveLength(1);
expect(out[0].arrType).toBe('sonarr');
expect(out[0].arrQueueId).toBe(9001);
});
it('matches a Transmission torrent via hashString', async () => {
const hashString = 'TRANSMISSIONHASH456';
const context = makeContext({
radarrQueueRecords: [
{
id: 7777,
title: 'unrelated',
sourceTitle: '',
movieId: 200,
movie: movieFilmB,
downloadId: hashString
}
]
});
const torrents = [
{
hashString,
name: 'Film.B.2160p.WEB.x265-RELEASE',
progress: 0.25,
dlspeed: 0
}
];
const out = await DownloadMatcher.matchTorrents(torrents, context);
expect(out).toHaveLength(1);
expect(out[0].arrType).toBe('radarr');
expect(out[0].arrQueueId).toBe(7777);
});
it('falls back to title-substring matching when no hash is present on the torrent', async () => {
const context = makeContext({
sonarrQueueRecords: [
{
id: 555,
title: 'Show A',
sourceTitle: '',
seriesId: 100,
series: seriesShowA,
downloadId: null
}
]
});
const torrents = [
{
// No hash / hashString — title fallback must engage.
name: 'Show A — S01E02',
progress: 0.7,
dlspeed: 5000
}
];
const out = await DownloadMatcher.matchTorrents(torrents, context);
expect(out).toHaveLength(1);
expect(out[0].arrType).toBe('sonarr');
expect(out[0].arrQueueId).toBe(555);
});
});
describe('matchTorrents — arrQueueId deduplication (#65)', () => {
it('deduplicates two torrents matching distinct queue records sharing one arrQueueId via the same hash', async () => {
// Construct the pathological case the dedup step is designed for: two
// torrents (post-hash-match) both end up mapped to the same arrQueueId.
// In real life this happens when *arr exposes multiple queue rows under
// one logical download. The first matched download wins; subsequent ones
// are dropped.
const hash = 'PACKHASH001';
const sharedQueueRow = {
id: 4242, // same arrQueueId
title: 'unrelated',
sourceTitle: '',
seriesId: 100,
series: seriesShowA,
downloadId: hash
};
const context = makeContext({
sonarrQueueRecords: [sharedQueueRow]
});
const torrents = [
{ hash, name: 'Show.A.S02E01', progress: 0.1, dlspeed: 0 },
{ hash, name: 'Show.A.S02E02', progress: 0.2, dlspeed: 0 }
];
const out = await DownloadMatcher.matchTorrents(torrents, context);
expect(out).toHaveLength(1);
expect(out[0].arrQueueId).toBe(4242);
});
it('does not deduplicate torrents that lack arrQueueId (no matched *arr record)', async () => {
const context = makeContext();
const torrents = [
{ hash: 'no-match-A', name: 'unmatched-A', progress: 0, dlspeed: 0 },
{ hash: 'no-match-B', name: 'unmatched-B', progress: 0, dlspeed: 0 }
];
const out = await DownloadMatcher.matchTorrents(torrents, context);
// Both unmatched torrents are filtered out by the matcher entirely because
// there is nothing to match against — so the deduplicator never sees them.
// This test simply asserts the dedup step itself does not collapse
// non-arr entries into a single bucket when no key is present.
expect(out).toEqual([]);
});
});
+108 -3
View File
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
});
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
// 1. Setup mock instance config
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
]);
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
const tvRequestsWithTvDbId = [
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
];
nock.cleanAll();
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
nock('https://sonarr.test')
.get('/api/v3/series')
.reply(200, [
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
]);
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?showAll=true')
.set('Cookie', cookies)
.expect(200);
// 4. Assert decoration succeeded
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
expect(supermanShow).toBeDefined();
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
expect(supermanShow.arrType).toBe('sonarr');
// Clean up
delete process.env.SONARR_INSTANCES;
});
it('handles case-insensitive username matching', async () => {
const requestsWithMixedCase = [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
@@ -850,7 +892,15 @@ describe('POST /api/ombi/webhook/enable', () => {
it('enables webhook successfully', async () => {
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook')
.get('/api/v1/Settings/notifications/webhook')
.reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null });
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook', {
id: 42,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
@@ -862,11 +912,38 @@ describe('POST /api/ombi/webhook/enable', () => {
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
expect(res.body.applicationToken).toBe('test-ombi-key');
});
it('enables webhook successfully even if GET settings fails', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Failed to fetch settings' });
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook', {
id: 0,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
expect(res.body.success).toBe(true);
});
it('handles Ombi API errors gracefully', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, { id: 42 });
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Internal server error' });
@@ -979,10 +1056,16 @@ describe('POST /api/ombi/webhook/test', () => {
expect(webhookScope.isDone()).toBe(true);
});
it('handles webhook send errors gracefully', async () => {
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
nock(SOFARR_BASE)
.post('/api/webhook/ombi')
.reply(500, { error: 'Internal server error' });
nock('http://127.0.0.1:3001')
.post('/api/webhook/ombi')
.reply(500, { error: 'Internal server error' });
nock('https://127.0.0.1:3001')
.post('/api/webhook/ombi')
.reply(500, { error: 'Internal server error' });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
@@ -994,4 +1077,26 @@ describe('POST /api/ombi/webhook/test', () => {
expect(res.body.error).toBe('Failed to test Ombi webhook');
});
it('falls back to local loopback when public URL request fails', async () => {
nock(SOFARR_BASE)
.post('/api/webhook/ombi')
.replyWithError('Connection refused');
nock('http://127.0.0.1:3001')
.post('/api/webhook/ombi')
.reply(200, { received: true });
nock('https://127.0.0.1:3001')
.post('/api/webhook/ombi')
.reply(200, { received: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
expect(res.body.success).toBe(true);
});
});
+142
View File
@@ -0,0 +1,142 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
const SONARR_BASE = 'https://sonarr-decor.test';
const RADARR_BASE = 'https://radarr-decor.test';
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
beforeEach(() => {
vi.restoreAllMocks();
nock.cleanAll();
// Reset the singleton retrievers registry so we can inject our test instances
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
// Configure test environment variables for retrievers
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
]);
});
afterEach(() => {
nock.cleanAll();
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
});
it('decorates a series download with Sonarr link matching on title', async () => {
// Mock Sonarr series query
nock(SONARR_BASE)
.get('/api/v3/series')
.reply(200, [
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
]);
// Mock Radarr movie query (empty)
nock(RADARR_BASE)
.get('/api/v3/movie')
.reply(200, []);
const downloads = [
{
title: 'The.Mandalorian.S01E01.1080p',
type: 'series',
seriesName: 'The Mandalorian',
arrSeriesId: null
}
];
await decorateDownloadsWithArrLinks(downloads, true);
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
expect(downloads[0].arrType).toBe('sonarr');
});
it('decorates a movie download with Radarr link matching on content ID', async () => {
// Mock Sonarr series query (empty)
nock(SONARR_BASE)
.get('/api/v3/series')
.reply(200, []);
// Mock Radarr movie query with matching ID
nock(RADARR_BASE)
.get('/api/v3/movie')
.reply(200, [
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
]);
const downloads = [
{
title: 'Blade.Runner.2049.2017.1080p',
type: 'movie',
movieName: 'Blade Runner 2049',
arrInstanceUrl: RADARR_BASE,
arrContentId: 99
}
];
await decorateDownloadsWithArrLinks(downloads, true);
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
expect(downloads[0].arrType).toBe('radarr');
});
it('skips decoration entirely when isAdmin is false', async () => {
const downloads = [
{
title: 'The.Mandalorian.S01E01.1080p',
type: 'series',
seriesName: 'The Mandalorian'
}
];
// No nocks are set up, so any HTTP calls would throw or error
await decorateDownloadsWithArrLinks(downloads, false);
expect(downloads[0].arrLink).toBeUndefined();
expect(downloads[0].arrType).toBeUndefined();
});
it('handles empty downloads array gracefully', async () => {
// No mock setups needed, should complete without throwing
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
});
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
// Mock Sonarr series query throwing connection error
nock(SONARR_BASE)
.get('/api/v3/series')
.replyWithError('connection refused');
// Mock Radarr movie query throwing timeout error
nock(RADARR_BASE)
.get('/api/v3/movie')
.replyWithError('timeout');
const downloads = [
{
title: 'The.Mandalorian.S01E01.1080p',
type: 'series',
seriesName: 'The Mandalorian'
}
];
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
// No links decorated since the fetch failed
expect(downloads[0].arrLink).toBeUndefined();
expect(downloads[0].arrType).toBeUndefined();
});
});
+65
View File
@@ -0,0 +1,65 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import nock from 'nock';
describe('Rate Limiting Integration Tests', () => {
let app;
let originalSkipRateLimit;
beforeEach(async () => {
// Save current rate limiting skip flag
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
// Explicitly delete it before loading the app so rate limiters are active
delete process.env.SKIP_RATE_LIMIT;
process.env.EMBY_URL = 'https://emby.test';
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
const appModule = await import('../../server/app.js');
const createApp = appModule.createApp;
// Create a new app instance with rate limiting enabled
app = createApp({ skipRateLimits: false });
nock.cleanAll();
});
afterEach(() => {
// Restore rate limit skip flag
if (originalSkipRateLimit !== undefined) {
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
} else {
delete process.env.SKIP_RATE_LIMIT;
}
delete process.env.EMBY_URL;
nock.cleanAll();
});
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
// Mock Emby server auth endpoint to return 401 (failed credentials).
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
// count toward the rate limit window of 10 requests.
nock('https://emby.test')
.post('/Users/authenticatebyname')
.reply(401, { error: 'Unauthorized' })
.persist();
// Fire 10 rapid failed login requests (the limit is 10)
for (let i = 0; i < 10; i++) {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'wrongpassword' });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Invalid username or password');
}
// The 11th request must be rate limited and return 429
const limitRes = await request(app)
.post('/api/auth/login')
.send({ username: 'TestUser', password: 'wrongpassword' });
expect(limitRes.status).toBe(429);
expect(limitRes.body.error).toContain('Too many login attempts');
});
});
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
});
it('should have Debug logging endpoints documented', () => {
const paths = openapiSpec.paths;
expect(paths['/api/debug/status']).toBeDefined();
expect(paths['/api/debug/status'].get).toBeDefined();
expect(paths['/api/debug/server-logs']).toBeDefined();
expect(paths['/api/debug/server-logs'].get).toBeDefined();
expect(paths['/api/debug/client-logs']).toBeDefined();
expect(paths['/api/debug/client-logs'].get).toBeDefined();
expect(paths['/api/debug/client-logs'].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);
+260 -1
View File
@@ -97,6 +97,9 @@ function makeApp() {
process.env.RADARR_INSTANCES = JSON.stringify([
{ 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 });
}
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
}
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://radarr.test').persist().get(/.*/).reply(200, { records: [] });
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
});
afterEach(() => {
@@ -125,6 +129,7 @@ afterEach(() => {
delete process.env.SOFARR_BASE_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
delete process.env.OMBI_INSTANCES;
});
// ---------------------------------------------------------------------------
@@ -151,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
const res = await postSonarr(app, SONARR_GRAB, 'anything');
expect(res.status).toBe(401);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
.send(SONARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr?secret=wrong-query-secret')
.send(SONARR_GRAB);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
});
describe('POST /api/webhook/radarr — secret validation', () => {
@@ -166,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
expect(res.status).toBe(401);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
.send(RADARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/radarr?secret=wrong-query-secret')
.send(RADARR_GRAB);
expect(res.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
@@ -347,6 +387,38 @@ describe('Replay protection', () => {
expect(first.body.duplicate).toBeUndefined();
expect(second.body.duplicate).toBeUndefined();
});
it('sonarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
instanceName: 'Main Sonarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postSonarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postSonarr(app, payload);
expect(second.status).toBe(200);
expect(second.body.duplicate).toBeUndefined();
});
it('radarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
instanceName: 'Main Radarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postRadarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postRadarr(app, payload);
expect(second.status).toBe(200);
expect(second.body.duplicate).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
@@ -518,3 +590,190 @@ describe('GET /api/webhook/config', () => {
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 200 when secret is provided as a query parameter instead of header', 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 res = await request(app)
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
.send({
notificationType: 'NewRequest',
requestId: 127,
requestedUser: 'gordon',
title: 'Query Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:40:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/ombi?secret=wrong-query-secret')
.send({ notificationType: 'NewRequest', requestId: 1 });
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();
});
});
+149
View File
@@ -183,3 +183,152 @@ describe('arrRetrieverRegistry', () => {
});
});
});
describe('OmbiRetriever._hydrateRequest', () => {
let retriever;
beforeEach(() => {
retriever = new OmbiRetriever({
id: 'ombi-test',
name: 'Test Ombi',
url: 'http://localhost:5000',
apiKey: 'test-key'
});
// Seed the userMap cache
retriever.cache.userMap.set('user-1', {
id: 'user-1',
userName: 'testuser',
alias: 'TestUser',
userAlias: 'TestUser',
normalizedUserName: 'testuser'
});
retriever.cache.userMap.set('user-2', {
id: 'user-2',
userName: 'adminuser',
alias: 'AdminUser',
userAlias: 'AdminUser',
normalizedUserName: 'adminuser'
});
});
it('hydrates top-level requestedUserId', () => {
const req = {
id: 1,
requestedUserId: 'user-1',
requestedUser: {}
};
const result = retriever._hydrateRequest(req);
expect(result.requestedUser.userName).toBe('testuser');
expect(result.requestedUser.alias).toBe('TestUser');
});
it('hydrates childRequests requestedUserId (TV requests)', () => {
const req = {
id: 3,
title: 'Test Show',
requestedUserId: 'user-1',
requestedUser: {},
childRequests: [
{
id: 10,
requestedUserId: 'user-2',
requestedUser: {}
}
]
};
const result = retriever._hydrateRequest(req);
expect(result.requestedUser.userName).toBe('testuser');
expect(result.childRequests[0].requestedUser.userName).toBe('adminuser');
expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser');
});
it('promotes requestedDate from childRequests to top level', () => {
const req = {
id: 3,
title: 'Test Show',
childRequests: [
{
id: 10,
requestedDate: '2026-05-15T10:00:00.000Z'
}
]
};
const result = retriever._hydrateRequest(req);
expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z');
expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z');
});
it('does not overwrite existing top-level requestedDate', () => {
const req = {
id: 3,
requestedDate: '2026-01-01T00:00:00.000Z',
childRequests: [
{
id: 10,
requestedDate: '2026-05-15T10:00:00.000Z'
}
]
};
const result = retriever._hydrateRequest(req);
expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z');
});
it('handles PascalCase RequestedDate from childRequests', () => {
const req = {
id: 3,
childRequests: [
{
id: 10,
RequestedDate: '2026-06-01T12:00:00.000Z'
}
]
};
const result = retriever._hydrateRequest(req);
expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z');
});
it('returns unmodified request when no hydration needed', () => {
const req = {
id: 1,
title: 'Test Movie',
requestedUser: { userName: 'existing', alias: 'Existing' }
};
const result = retriever._hydrateRequest(req);
expect(result).toEqual(req);
});
it('handles null childRequests gracefully', () => {
const req = {
id: 3,
childRequests: null
};
const result = retriever._hydrateRequest(req);
expect(result).toEqual(req);
});
it('handles empty childRequests gracefully', () => {
const req = {
id: 3,
childRequests: []
};
const result = retriever._hydrateRequest(req);
expect(result).toEqual(req);
});
it('skips child hydration when child already has valid requestedUser', () => {
const req = {
id: 3,
childRequests: [
{
id: 10,
requestedUserId: 'user-1',
requestedUser: { userName: 'already_set', alias: 'AlreadySet' }
}
]
};
const result = retriever._hydrateRequest(req);
expect(result.childRequests[0].requestedUser.userName).toBe('already_set');
expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet');
});
});
+43
View File
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
expect(result).toBe(false);
});
});
describe('getUsers', () => {
it('should return user array for successful request', async () => {
const mockUsers = [
{ id: '1', userName: 'Gordon' },
{ id: '2', userName: 'Alice' }
];
nock(baseUrl)
.get('/api/v1/Identity/Users')
.matchHeader('ApiKey', apiKey)
.reply(200, mockUsers);
const client = new OmbiClient(baseUrl, apiKey);
const result = await client.getUsers();
expect(result).toEqual(mockUsers);
});
it('should return empty array on API error', async () => {
nock(baseUrl)
.get('/api/v1/Identity/Users')
.matchHeader('ApiKey', apiKey)
.reply(500, { error: 'Internal Server Error' });
const client = new OmbiClient(baseUrl, apiKey);
const result = await client.getUsers();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
nock(baseUrl)
.get('/api/v1/Identity/Users')
.matchHeader('ApiKey', apiKey)
.replyWithError('Network error');
const client = new OmbiClient(baseUrl, apiKey);
const result = await client.getUsers();
expect(result).toEqual([]);
});
});
});
+157
View File
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
expect(retriever.cache.movieRequests).toHaveLength(2);
});
it('should refresh if cache is not expired but force is true', async () => {
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
const mockTvShows = [];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies1);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const retriever = new OmbiRetriever(instanceConfig);
// First refresh
await retriever.refreshCache();
expect(retriever.cache.movieRequests).toHaveLength(1);
// Set up new mocks for second refresh without advancing time
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies2);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
// Second refresh with force=true should make API calls
await retriever.refreshCache(true);
expect(retriever.cache.movieRequests).toHaveLength(2);
});
it('should build movie map with TMDB and IMDB IDs', async () => {
const mockMovies = [
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockMovies);
});
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
const mockTvShows = [];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies1);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const retriever = new OmbiRetriever(instanceConfig);
await retriever.refreshCache();
// Set up new mocks for second fetch
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies2);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const result = await retriever.getMovieRequests(true);
expect(result).toEqual(mockMovies2);
});
});
describe('getTvRequests', () => {
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockTvShows);
});
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
const mockMovies = [];
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows1);
const retriever = new OmbiRetriever(instanceConfig);
await retriever.refreshCache();
// Set up new mocks for second fetch
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows2);
const result = await retriever.getTvRequests(true);
expect(result).toEqual(mockTvShows2);
});
});
describe('findMovieRequest', () => {
@@ -675,4 +766,70 @@ describe('OmbiRetriever', () => {
expect(stats.age).toBeGreaterThanOrEqual(0);
});
});
describe('hydration logic', () => {
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
const mockMovies = [
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
];
const mockTvShows = [];
const mockUsers = [
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
nock(baseUrl)
.get('/api/v1/Identity/Users')
.reply(200, mockUsers);
const retriever = new OmbiRetriever(instanceConfig);
const result = await retriever.getMovieRequests();
expect(result).toHaveLength(1);
expect(result[0].requestedUser).toBeDefined();
expect(result[0].requestedUser.userName).toBe('Gordon');
expect(result[0].requestedUser.alias).toBe('G-Man');
});
it('should not overwrite non-empty requestedUser object', async () => {
const mockMovies = [
{
id: 1,
title: 'Movie 1',
requestedUserId: 'gordon-id',
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
}
];
const mockTvShows = [];
const mockUsers = [
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
nock(baseUrl)
.get('/api/v1/Identity/Users')
.reply(200, mockUsers);
const retriever = new OmbiRetriever(instanceConfig);
const result = await retriever.getMovieRequests();
expect(result).toHaveLength(1);
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
expect(result[0].requestedUser.alias).toBe('ExistingG');
});
});
});
@@ -130,6 +130,8 @@ describe('QBittorrentClient', () => {
downloaded: 750000000,
speed: 1048576,
eta: 3600,
seeds: 0,
peers: 0,
category: 'movies',
tags: ['movie', 'hd'],
savePath: '/downloads/test',
@@ -138,6 +140,28 @@ describe('QBittorrentClient', () => {
});
});
it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => {
const torrent = {
hash: 'def456',
name: 'Swarm Torrent',
state: 'downloading',
progress: 0.1,
size: 1000,
completed: 100,
dlspeed: 0,
eta: 0,
num_seeds: 7,
num_leechs: 3,
// Swarm totals — must NOT be picked up as connected counts
num_complete: 200,
num_incomplete: 50
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.seeds).toBe(7);
expect(normalized.peers).toBe(3);
});
it('should handle unknown torrent states', () => {
const torrent = {
hash: 'abc123',
+64
View File
@@ -420,4 +420,68 @@ describe('RTorrentClient', () => {
expect(status).toBeNull();
});
});
describe('Null-safety (Issue #68)', () => {
it('should return [] when d.multicall2 returns a non-array', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, null);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should skip malformed individual torrent rows instead of throwing', async () => {
const torrents = [
// valid row
['hashA', 'Name A', 100, 50, 0, 0, 1, 1, 0, '/dl', ''],
// malformed row (not an array)
'not-an-array',
// row with null/undefined fields
['hashB', null, null, null, null, null, null, null, null, null, null]
];
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, torrents);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('hashA');
expect(downloads[1].id).toBe('hashB');
expect(downloads[1].title).toBe('');
expect(downloads[1].size).toBe(0);
});
it('_extractArrInfo should return {} for non-string filename', () => {
expect(client._extractArrInfo(null)).toEqual({});
expect(client._extractArrInfo(undefined)).toEqual({});
expect(client._extractArrInfo(123)).toEqual({});
});
});
describe('lastError tracking (Issue #68)', () => {
it('should record lastError on getActiveDownloads failure', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('boom'));
});
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
expect(client.getLastError().operation).toBe('getActiveDownloads');
expect(client.getLastError().message).toBe('boom');
});
it('should clear lastError on successful call', async () => {
// First, fail.
mockMethodCall.mockImplementationOnce((method, params, callback) => {
callback(new Error('boom'));
});
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
// Then, succeed.
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, []);
});
await client.getActiveDownloads();
expect(client.getLastError()).toBeNull();
});
});
});
+59
View File
@@ -299,4 +299,63 @@ describe('SABnzbdClient', () => {
expect(status).toBeNull();
});
});
describe('History limit configuration (Issue #68)', () => {
const ORIG_ENV = process.env.SAB_HISTORY_LIMIT;
afterEach(() => {
if (ORIG_ENV === undefined) delete process.env.SAB_HISTORY_LIMIT;
else process.env.SAB_HISTORY_LIMIT = ORIG_ENV;
});
it('defaults historyLimit to 10 when SAB_HISTORY_LIMIT is unset', () => {
delete process.env.SAB_HISTORY_LIMIT;
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(10);
});
it('honors SAB_HISTORY_LIMIT when set to a valid integer', () => {
process.env.SAB_HISTORY_LIMIT = '25';
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(25);
});
it('falls back to default on invalid SAB_HISTORY_LIMIT', () => {
process.env.SAB_HISTORY_LIMIT = 'not-a-number';
const c = new SABnzbdClient(mockConfig);
expect(c.historyLimit).toBe(10);
});
it('passes historyLimit through to the history API call', async () => {
process.env.SAB_HISTORY_LIMIT = '42';
const c = new SABnzbdClient(mockConfig);
const makeRequest = vi.fn()
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
c.makeRequest = makeRequest;
await c.getActiveDownloads();
expect(makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 42 });
});
});
describe('lastError tracking (Issue #68)', () => {
it('records lastError when getActiveDownloads fails', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
expect(client.getLastError().operation).toBe('getActiveDownloads');
expect(client.getLastError().message).toBe('boom');
});
it('clears lastError after a subsequent successful call', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
await client.getActiveDownloads();
expect(client.getLastError()).not.toBeNull();
client.makeRequest = vi.fn()
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
await client.getActiveDownloads();
expect(client.getLastError()).toBeNull();
});
});
});
+41 -1
View File
@@ -154,7 +154,9 @@ describe('TransmissionClient', () => {
4: 'Downloading',
5: 'Queued',
6: 'Seeding',
7: 'Unknown'
// Issue #63: code 7 is undocumented in the RPC spec; mapped to
// `Checking` (legacy alias for code 2) as a best-effort interpretation.
7: 'Checking'
};
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
@@ -433,4 +435,42 @@ describe('TransmissionClient', () => {
expect(normalized.arrType).toBeUndefined();
});
});
describe('Torrent Control Methods (Issue #63)', () => {
it('startTorrent invokes torrent-start RPC with ids array', async () => {
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
await client.startTorrent('abc123');
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: ['abc123'] });
});
it('startTorrent accepts an array of ids', async () => {
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
await client.startTorrent([1, 2, 3]);
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: [1, 2, 3] });
});
it('stopTorrent invokes torrent-stop RPC', async () => {
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
await client.stopTorrent(42);
expect(client.makeRequest).toHaveBeenCalledWith('torrent-stop', { ids: [42] });
});
it('removeTorrent invokes torrent-remove with delete-local-data=false by default', async () => {
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
await client.removeTorrent('hashX');
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
ids: ['hashX'],
'delete-local-data': false
});
});
it('removeTorrent passes delete-local-data=true when requested', async () => {
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
await client.removeTorrent('hashY', true);
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
ids: ['hashY'],
'delete-local-data': true
});
});
});
});
+2 -1
View File
@@ -310,7 +310,8 @@ describe('DownloadClientRegistry', () => {
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
status: { status: 'active' }
status: { status: 'active' },
lastError: null
});
});
+34
View File
@@ -58,6 +58,40 @@ describe('getRequestStatus', () => {
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
});
it('returns available from childRequests when top-level is absent (TV)', () => {
expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available');
});
it('returns denied from childRequests when top-level is absent (TV)', () => {
expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied');
});
it('returns approved from childRequests when top-level is absent (TV)', () => {
expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved');
});
it('returns pending from childRequests when top-level is absent (TV)', () => {
expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending');
});
it('follows priority inside childRequests: available > denied > approved > pending', () => {
expect(getRequestStatus({ childRequests: [
{ available: true, denied: true },
{ approved: true }
]})).toBe('available');
expect(getRequestStatus({ childRequests: [
{ denied: true, approved: true },
{ requested: true }
]})).toBe('denied');
expect(getRequestStatus({ childRequests: [
{ approved: true, requested: true }
]})).toBe('approved');
});
it('returns unknown for TV request with empty childRequests', () => {
expect(getRequestStatus({ childRequests: [] })).toBe('unknown');
});
});
// ---------------------------------------------------------------------------
+79
View File
@@ -79,6 +79,85 @@ describe('ombiHelpers', () => {
};
expect(extractRequestedUser(req)).toBe('');
});
it('returns userName from nested user object', () => {
const req = { user: { userName: 'user_val' } };
expect(extractRequestedUser(req)).toBe('user_val');
});
it('returns alias from nested requestedBy object', () => {
const req = { requestedBy: { alias: 'req_alias' } };
expect(extractRequestedUser(req)).toBe('req_alias');
});
it('returns normalizedUserName from nested ombiUser object', () => {
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
expect(extractRequestedUser(req)).toBe('norm_ombi');
});
it('returns userAlias from nested requestedByUser object', () => {
const req = { requestedByUser: { userAlias: 'alias_user' } };
expect(extractRequestedUser(req)).toBe('alias_user');
});
it('returns username from a string source value', () => {
const req = { requestedBy: 'direct_string' };
expect(extractRequestedUser(req)).toBe('direct_string');
});
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
});
it('recursively extracts user from seasons array requests', () => {
const req = {
seasons: [
{},
{ requestedUser: { alias: 'season_user' } }
]
};
expect(extractRequestedUser(req)).toBe('season_user');
});
it('recursively extracts user from childRequests array', () => {
const req = {
childRequests: [
{},
{ user: { userName: 'child_user' } }
]
};
expect(extractRequestedUser(req)).toBe('child_user');
});
it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => {
const req = {
childRequests: [
{},
{ requestedUser: { userName: 'tv_user', alias: 'tv_alias' } }
]
};
expect(extractRequestedUser(req)).toBe('tv_alias');
});
it('recursively extracts user from childRequests requestedUser as string', () => {
const req = {
childRequests: [
{ requestedUser: 'string_user' }
]
};
expect(extractRequestedUser(req)).toBe('string_user');
});
it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => {
const req = {
childRequests: [
{ requestedByAlias: 'deep_alias' }
]
};
expect(extractRequestedUser(req)).toBe('deep_alias');
});
});
describe('filterRequestsByUser', () => {
+154
View File
@@ -925,4 +925,158 @@ describe('buildUserDownloads', () => {
expect(result[0].arrLink).toBe('https://sonarr.test/series/test-series');
expect(result[1].arrLink).toBe('https://radarr.test/movie/test-movie');
});
describe('orphaned download integration in DownloadBuilder', () => {
it('returns orphaned queue records when no active client match is found', async () => {
const cacheSnapshot = {
sabnzbdQueue: { data: { queue: { slots: [] } } },
sabnzbdHistory: { data: { history: { slots: [] } } },
sonarrQueue: {
data: {
records: [{
id: 500,
title: 'Genuinely Orphaned Show',
sourceTitle: 'Genuinely Orphaned Show',
seriesId: 1,
series: seriesMap.get(1),
size: 200000000,
sizeleft: 100000000,
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Missing files'] }]
}]
}
},
sonarrHistory: { data: { records: [] } },
radarrQueue: { data: { records: [] } },
radarrHistory: { data: { records: [] } },
qbittorrentTorrents: []
};
const result = await buildUserDownloads(cacheSnapshot, {
username,
usernameSanitized,
isAdmin: true,
showAll,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
embyUserMap
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
title: 'Genuinely Orphaned Show',
isOrphaned: true,
client: 'orphaned',
instanceId: 'orphaned',
instanceName: 'Orphaned (unconfigured client)',
progress: 50,
importIssues: ['Missing files']
});
});
it('strictly deduplicates so active matched items are NOT duplicated as orphans', async () => {
const cacheSnapshot = {
sabnzbdQueue: {
data: {
queue: {
status: 'Downloading',
speed: '5.0 MB/s',
kbpersec: 5120,
slots: [{
filename: 'Matched Active Show',
nzbname: 'Matched Active Show',
status: 'Downloading',
percentage: 50,
mb: 1000,
mbmissing: 500,
size: '1 GB',
timeleft: '10:00'
}]
}
}
},
sabnzbdHistory: { data: { history: { slots: [] } } },
sonarrQueue: {
data: {
records: [{
id: 100,
downloadId: '100', // matches slot by default mock (or slot.id/nzo_id)
title: 'Matched Active Show',
sourceTitle: 'Matched Active Show',
seriesId: 1,
series: seriesMap.get(1)
}]
}
},
sonarrHistory: { data: { records: [] } },
radarrQueue: { data: { records: [] } },
radarrHistory: { data: { records: [] } },
qbittorrentTorrents: []
};
// Set slot nzo_id to match the downloadId
cacheSnapshot.sabnzbdQueue.data.queue.slots[0].nzo_id = '100';
const result = await buildUserDownloads(cacheSnapshot, {
username,
usernameSanitized,
isAdmin: true,
showAll,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
embyUserMap
});
// Should match the download once via SABnzbd client; should NOT list it again as an orphan
expect(result).toHaveLength(1);
expect(result[0].isOrphaned).toBeUndefined();
expect(result[0].client).toBe('sabnzbd');
});
it('filters orphaned records based on user tag matches', async () => {
const cacheSnapshot = {
sabnzbdQueue: { data: { queue: { slots: [] } } },
sabnzbdHistory: { data: { history: { slots: [] } } },
sonarrQueue: {
data: {
records: [{
id: 600,
title: 'Bobs Orphaned Show',
sourceTitle: 'Bobs Orphaned Show',
seriesId: 2, // Bob's series (tag=2, username=bob)
series: {
id: 2,
title: 'Bob Show',
tags: [2],
images: []
}
}]
}
},
sonarrHistory: { data: { records: [] } },
radarrQueue: { data: { records: [] } },
radarrHistory: { data: { records: [] } },
qbittorrentTorrents: []
};
const result = await buildUserDownloads(cacheSnapshot, {
username: 'alice', // alice should not see bob's orphaned downloads
usernameSanitized: 'alice',
isAdmin: false,
showAll: false,
seriesMap: new Map([[2, { id: 2, title: 'Bob Show', tags: [2], images: [] }]]),
moviesMap,
sonarrTagMap: new Map([[1, 'alice'], [2, 'bob']]),
radarrTagMap,
embyUserMap
});
expect(result).toEqual([]);
});
});
});
+136
View File
@@ -145,4 +145,140 @@ describe('DownloadMatcher', () => {
expect(result.speed).toBe('1.5 MB/s');
});
});
describe('buildArrDownload', () => {
const context = {
seriesMap: new Map([[1, { id: 1, title: 'My Show', tags: [1] }]]),
moviesMap: new Map([[2, { id: 2, title: 'My Movie', tags: [1] }]]),
sonarrTagMap: new Map([[1, 'alice']]),
radarrTagMap: new Map([[1, 'alice']]),
username: 'alice',
isAdmin: false,
showAll: false,
embyUserMap: new Map()
};
it('correctly uses caller-supplied client, instanceId, and instanceName values', () => {
const record = { id: 100, seriesId: 1, title: 'My Show' };
const dl = DownloadMatcher.buildArrDownload(record, context, {
client: 'deluge',
instanceId: 'deluge-1',
instanceName: 'Deluge Instance 1'
});
expect(dl).toBeDefined();
expect(dl.client).toBe('deluge');
expect(dl.instanceId).toBe('deluge-1');
expect(dl.instanceName).toBe('Deluge Instance 1');
});
it('uses neutral fallback defaults when not supplied', () => {
const record = { id: 100, seriesId: 1, title: 'My Show' };
const dl = DownloadMatcher.buildArrDownload(record, context);
expect(dl).toBeDefined();
expect(dl.client).toBe('orphaned');
expect(dl.instanceId).toBe('orphaned');
expect(dl.instanceName).toBe('Unknown');
});
it('uses correct blocklist determination and defaults progress to 0', () => {
const record = { id: 100, seriesId: 1, title: 'My Show' };
const dl = DownloadMatcher.buildArrDownload(record, context);
expect(dl.progress).toBe(0);
expect(dl.canBlocklist).toBe(false);
});
});
describe('matchSabHistory', () => {
const context = {
sonarrHistoryRecords: [
{ id: 100, downloadId: 'sabnzbd_1234', seriesId: 1 }
],
sonarrQueueRecords: [
{ id: 101, downloadId: 'sabnzbd_5678', seriesId: 1 }
],
radarrHistoryRecords: [],
radarrQueueRecords: [],
seriesMap: new Map([[1, { id: 1, title: 'Show 1', tags: [1] }]]),
moviesMap: new Map(),
sonarrTagMap: new Map([[1, 'alice']]),
radarrTagMap: new Map(),
username: 'alice',
isAdmin: false,
showAll: false,
embyUserMap: new Map()
};
it('matches by downloadId case-insensitively and type-safely', async () => {
const slots = [{ id: 'SABNZBD_1234', name: 'Show 1', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, context);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(100);
});
it('dual-lookup: matches history slots against active queue records', async () => {
const slots = [{ id: 'sabnzbd_5678', name: 'Show 1', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, context);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(101);
});
});
describe('titleMatches helper', () => {
it('matches identical strings and handles dots/spaces/dashes bidirectionally', () => {
// Direct exports or internal reference
const titleMatches = DownloadMatcher.__get__ ? DownloadMatcher.__get__('titleMatches') : null;
// Since it is not exported, we can test it indirectly via matchTorrents or call it if we export it,
// or we can test it via matchTorrents fallback matching. Let's test it using matchTorrents with dot names:
});
});
describe('matchOrphanedArrRecords', () => {
const context = {
sonarrQueueRecords: [
{ id: 100, seriesId: 1, title: 'Orphan 1', size: 1000, sizeleft: 400 },
{ id: 101, seriesId: 1, title: 'Already Matched', size: 1000, sizeleft: 0 }
],
radarrQueueRecords: [],
seriesMap: new Map([[1, { id: 1, title: 'Series 1', tags: [1] }]]),
moviesMap: new Map(),
sonarrTagMap: new Map([[1, 'alice']]),
radarrTagMap: new Map(),
username: 'alice',
isAdmin: false,
showAll: false,
embyUserMap: new Map()
};
it('constructs orphans, filters matched IDs, and computes safe progress math', () => {
const matchedIds = new Set([101]);
const result = DownloadMatcher.matchOrphanedArrRecords(matchedIds, context);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
title: 'Orphan 1',
isOrphaned: true,
progress: 60,
client: 'orphaned',
instanceId: 'orphaned'
});
});
it('handles size=0 safely without returning NaN or Infinity', () => {
const zeroContext = {
...context,
sonarrQueueRecords: [
{ id: 102, seriesId: 1, title: 'Zero Size', size: 0, sizeleft: 0 }
]
};
const result = DownloadMatcher.matchOrphanedArrRecords(new Set(), zeroContext);
expect(result).toHaveLength(1);
expect(result[0].progress).toBe(0);
});
});
});
+142
View File
@@ -0,0 +1,142 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Tests for the shared `buildArrQueueCache` helper (Issue #61).
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { buildArrQueueCache } = require('../../../server/utils/arrQueueHelpers');
const sonarrInstances = [
{ id: 's1', url: 'http://sonarr-1', apiKey: 'KEY_S1', name: 'Sonarr 1' }
];
const radarrInstances = [
{ id: 'r1', url: 'http://radarr-1', apiKey: 'KEY_R1', name: 'Radarr 1' }
];
describe('buildArrQueueCache', () => {
it('returns empty array for empty / missing input', () => {
expect(buildArrQueueCache([], sonarrInstances, 'series')).toEqual([]);
expect(buildArrQueueCache(null, sonarrInstances, 'series')).toEqual([]);
expect(buildArrQueueCache(undefined, sonarrInstances, 'series')).toEqual([]);
});
it('returns empty array for invalid mediaKey', () => {
const queues = [{ instance: 's1', data: { records: [{ id: 1 }] } }];
expect(buildArrQueueCache(queues, sonarrInstances, 'bogus')).toEqual([]);
});
it('tags Sonarr records with _instanceUrl/_instanceKey and decorates embedded series', () => {
const queues = [
{
instance: 's1',
data: {
records: [
{ id: 1, downloadId: 'dl-1', series: { id: 100, title: 'X' } }
]
}
}
];
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
expect(out).toHaveLength(1);
expect(out[0]._instanceUrl).toBe('http://sonarr-1');
expect(out[0]._instanceKey).toBe('KEY_S1');
expect(out[0].series._instanceUrl).toBe('http://sonarr-1');
});
it('tags Radarr records and decorates embedded movie', () => {
const queues = [
{
instance: 'r1',
data: {
records: [
{ id: 11, downloadId: 'dl-r1', movie: { id: 555, title: 'M' } }
]
}
}
];
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
expect(out).toHaveLength(1);
expect(out[0]._instanceUrl).toBe('http://radarr-1');
expect(out[0]._instanceKey).toBe('KEY_R1');
expect(out[0].movie._instanceUrl).toBe('http://radarr-1');
});
it('annotates Sonarr season pack records (multiple entries sharing downloadId)', () => {
const queues = [
{
instance: 's1',
data: {
records: [
{ id: 1, downloadId: 'pack-A', episodeId: 101 },
{ id: 2, downloadId: 'pack-A', episodeId: 102 },
{ id: 3, downloadId: 'pack-A', episodeId: 103 },
{ id: 4, downloadId: 'single-B', episodeId: 200 }
]
}
}
];
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
expect(out).toHaveLength(4);
const packMembers = out.filter(r => r.downloadId === 'pack-A');
expect(packMembers).toHaveLength(3);
for (const r of packMembers) {
expect(r.isSeasonPack).toBe(true);
expect(r.episodeCount).toBe(3);
}
const single = out.find(r => r.downloadId === 'single-B');
expect(single.isSeasonPack).toBeUndefined();
expect(single.episodeCount).toBeUndefined();
});
it('does not annotate Radarr records as season packs even if downloadId repeats', () => {
const queues = [
{
instance: 'r1',
data: {
records: [
{ id: 1, downloadId: 'dup', movie: { id: 1 } },
{ id: 2, downloadId: 'dup', movie: { id: 2 } }
]
}
}
];
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
expect(out).toHaveLength(2);
for (const r of out) {
expect(r.isSeasonPack).toBeUndefined();
expect(r.episodeCount).toBeUndefined();
}
});
it('skips malformed records and continues', () => {
const queues = [
{
instance: 's1',
data: {
records: [
null,
{ id: 1, downloadId: 'ok', series: { id: 1 } }
]
}
},
null,
{ instance: 's1' } // no data property
];
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
expect(out).toHaveLength(1);
expect(out[0].id).toBe(1);
});
it('handles unknown instance id gracefully (null url/key)', () => {
const queues = [
{
instance: 'unknown-instance',
data: { records: [{ id: 1, downloadId: 'x' }] }
}
];
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
expect(out).toHaveLength(1);
expect(out[0]._instanceUrl).toBeNull();
expect(out[0]._instanceKey).toBeNull();
});
});
+257
View File
@@ -0,0 +1,257 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// Set environment variables before requiring any modules
process.env.POLL_INTERVAL = '5000';
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
const cache = require('../../../server/utils/cache.js');
const downloadClients = require('../../../server/utils/downloadClients.js');
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
const config = require('../../../server/utils/config.js');
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
sabnzbd: [],
qbittorrent: []
});
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
sonarr: [],
radarr: []
});
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
sonarr: [],
radarr: []
});
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
sonarr: [],
radarr: []
});
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
movie: [],
tv: []
});
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
lastGlobalWebhookTimestamp: null
});
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
// Now require the poller
const poller = require('../../../server/utils/poller.js');
describe('Background Poller Utility', () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-apply standard resolved values
initializeClientsSpy.mockResolvedValue(true);
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
arrRegistryInitializeSpy.mockResolvedValue(true);
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
getSonarrInstancesSpy.mockReturnValue([]);
getRadarrInstancesSpy.mockReturnValue([]);
getOmbiInstancesSpy.mockReturnValue([]);
cacheSetSpy.mockImplementation(() => {});
cacheGetSpy.mockReturnValue(null);
getWebhookMetricsSpy.mockReturnValue(null);
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
incrementPollsSkippedSpy.mockImplementation(() => {});
});
afterEach(() => {
poller.stopPoller();
vi.useRealTimers();
});
describe('Poller Core Logic', () => {
it('POLL_INTERVAL matches parsed environment variable', () => {
expect(poller.POLL_INTERVAL).toBe(5000);
expect(poller.POLLING_ENABLED).toBe(true);
});
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
let callbackFired = false;
const callback = () => {
callbackFired = true;
};
poller.onPollComplete(callback);
await poller.pollAllServices();
expect(callbackFired).toBe(true);
// Clean up/Deregister callback
poller.offPollComplete(callback);
callbackFired = false;
await poller.pollAllServices();
expect(callbackFired).toBe(false);
});
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
// Stub initializeClients to delay using a promise
let resolveInit;
const delayPromise = new Promise((resolve) => {
resolveInit = resolve;
});
initializeClientsSpy.mockImplementation(() => delayPromise);
// Start the first poll (which remains pending on initializeClients)
const firstPollPromise = poller.pollAllServices();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Trigger second poll immediately while first is in progress
await poller.pollAllServices();
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
// Resolve the delay to let the first poll finish
resolveInit();
await firstPollPromise;
});
it('resets the polling guard flag on error so future polls can run', async () => {
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
// Setup error spy
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await poller.pollAllServices();
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
// Verify polling flag has been reset in the finally block by running a successful poll
initializeClientsSpy.mockResolvedValue(true);
await poller.pollAllServices();
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
});
});
describe('Webhook-Based Instance Bypassing', () => {
it('skips polling for an instance with recent active webhook events', async () => {
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
const recentTimestamp = Date.now() - 60000;
getWebhookMetricsSpy.mockImplementation((url) => {
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
}
return null;
});
await poller.pollAllServices();
// Verify that skips are incremented for both
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
// Verify that Sonarr/Radarr-specific API retrievers were not called
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
});
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
const staleTimestamp = Date.now() - 11 * 60000;
getWebhookMetricsSpy.mockImplementation((url) => {
if (url === 'https://sonarr.test') {
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
}
return null;
});
await poller.pollAllServices();
// Should bypass the skip and perform a full poll
expect(getTagsByTypeSpy).toHaveBeenCalled();
});
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
// Mock recent metrics on individual level but stale globally
const recentTimestamp = Date.now() - 60000;
getWebhookMetricsSpy.mockImplementation((url) => {
if (url === 'https://sonarr.test') {
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
}
return null;
});
// Global webhook is stale
getGlobalWebhookMetricsSpy.mockReturnValue({
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
});
await poller.pollAllServices();
// Stale global webhooks should trigger fallback, bypassing the individual skip
expect(getTagsByTypeSpy).toHaveBeenCalled();
});
});
describe('Hybrid Timer Behavior (Fake Timers)', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('schedules periodic polls in startPoller on standard interval', async () => {
poller.startPoller();
// Triggered immediately on start (flush microtasks)
await vi.advanceTimersByTimeAsync(0);
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
// Advance time by 5000ms
await vi.advanceTimersByTimeAsync(5000);
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
// Advance by another 5000ms
await vi.advanceTimersByTimeAsync(5000);
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
poller.stopPoller();
});
it('clears intervals cleanly when stopPoller is called', async () => {
poller.startPoller();
await vi.advanceTimersByTimeAsync(0);
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
poller.stopPoller();
await vi.advanceTimersByTimeAsync(10000);
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
});
});
});
+4
View File
@@ -5,6 +5,10 @@ export default defineConfig({
test: {
// Global test helpers (describe, it, expect, vi) without per-file imports
globals: true,
// Increase test timeout to avoid transient timeouts under coverage/heavy loads
testTimeout: 15000,
// Run test files sequentially to avoid cross-test background event pollution
fileParallelism: false,
// Run each test file in an isolated module registry so module-level state
// (tokenStore cache, config singletons) doesn't leak between files
isolate: true,