Compare commits

...

61 Commits

Author SHA1 Message Date
gronod 5e49b76842 merge branch 'develop' into 'main' - Release v1.7.38
CI / Swagger Validation & Coverage (push) Successful in 1m23s
Create Release / release (push) Successful in 25s
CI / Security audit (push) Successful in 2m43s
Build and Push Docker Image / build (push) Successful in 1m31s
CI / Tests & coverage (push) Successful in 5m58s
2026-05-29 14:53:26 +01:00
gronod bbe99c1358 chore: bump version to 1.7.38 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m4s
Docs Check / Markdown lint (push) Successful in 1m27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m57s
CI / Swagger Validation & Coverage (push) Successful in 2m33s
CI / Security audit (push) Successful in 2m42s
Docs Check / Mermaid diagram parse check (push) Successful in 3m6s
CI / Tests & coverage (push) Successful in 6m11s
2026-05-29 14:53:15 +01:00
gronod b2a837b173 fix: support all slot name variations in DownloadMatcher to resolve cached SAB history matching (#74)
Build and Push Docker Image / build (push) Successful in 39s
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 1m14s
CI / Security audit (push) Has been cancelled
2026-05-29 14:51:52 +01:00
gronod 7e1e2dd564 merge branch 'develop' into 'main' - Release v1.7.37
CI / Swagger Validation & Coverage (push) Successful in 1m20s
Create Release / release (push) Successful in 10s
CI / Security audit (push) Successful in 2m47s
Build and Push Docker Image / build (push) Successful in 1m22s
CI / Tests & coverage (push) Successful in 6m18s
2026-05-29 14:37:45 +01:00
gronod b2aa4f23fa chore: bump version to 1.7.37 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m16s
Docs Check / Markdown lint (push) Successful in 1m36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m14s
CI / Security audit (push) Successful in 2m42s
Docs Check / Mermaid diagram parse check (push) Successful in 3m3s
CI / Swagger Validation & Coverage (push) Successful in 3m20s
CI / Tests & coverage (push) Successful in 6m38s
2026-05-29 14:37:36 +01:00
gronod 87387aaebe fix: resolve SABnzbd history matching asymmetry and unify search helpers (#74)
Build and Push Docker Image / build (push) Successful in 40s
CI / Security audit (push) Successful in 1m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Swagger Validation & Coverage (push) Successful in 1m36s
CI / Tests & coverage (push) Has been cancelled
2026-05-29 14:35:02 +01:00
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
56 changed files with 3376 additions and 1014 deletions
+8 -10
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,23 +27,17 @@ jobs:
if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building develop image tags: ${TAGS}"
else
RELEASE_NAME=${BRANCH#release/}
# Primary registry tags
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
# Gitea package registry tags
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image tags: ${TAGS}"
fi
+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:
+127
View File
@@ -4,6 +4,133 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.38] - 2026-05-29
### Fixed
- **SABnzbd History Legacy Slot Name Compatibility (Issue #74)** — Hardened SABnzbd active-download and history slot title matching in `DownloadMatcher.js` to support all slot name property variations (`filename`, `nzbname`, `name`, `nzb_name`). This ensures history matching succeeds against cached/legacy data schemas where the name is stored solely under the `filename` property, preventing completed downloads awaiting import from incorrectly displaying as `"Unknown"` client cards. Added unit tests for legacy `filename` slot matching compatibility.
## [1.7.37] - 2026-05-29
### Fixed
- **SABnzbd History Matching Symmetry (Issue #74)** — Consolidated SABnzbd active-download matching algorithms in `DownloadMatcher.js` by introducing a unified, type-safe internal helper `findSabMatch(sabDownloadId, nzbName, context, caller)`. Refactored `matchSabSlots` and `matchSabHistory` to route entirely through `findSabMatch`. This resolves a bug where completed SABnzbd downloads awaiting manual import in Sonarr or Radarr queues were incorrectly flagged as "unknown" client/"Orphaned (unconfigured client)". Added detailed unit tests to safeguard this behavior.
## [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
+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.
+31 -5
View File
@@ -424,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
@@ -474,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
+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>
+7 -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');
@@ -303,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
+12 -10
View File
@@ -119,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 || ''}`;
@@ -158,7 +158,8 @@ function createRequestCard(request) {
}
meta.appendChild(user);
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date;
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';
@@ -190,20 +191,21 @@ 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);
@@ -211,15 +213,15 @@ function createRequestCard(request) {
if (state.isAdmin && request.arrLink) {
const arrLink = document.createElement('a');
arrLink.className = `request-link ${request.arrType}-link`;
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';
arrIcon.className = 'request-icon';
arrLink.appendChild(arrIcon);
actions.appendChild(arrLink);
+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.22",
"version": "1.7.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.22",
"version": "1.7.38",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+5 -2
View File
@@ -1,11 +1,14 @@
{
"name": "sofarr",
"version": "1.7.22",
"version": "1.7.38",
"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",
+16 -16
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>
+91 -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;
@@ -2229,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 {
@@ -2249,37 +2278,46 @@ body {
}
.requests-list {
display: grid;
gap: 12px;
overflow-x: hidden;
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 {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--accent);
}
.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;
}
@@ -2289,11 +2327,11 @@ body {
}
.request-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 4px;
word-break: break-word;
}
.request-meta {
@@ -2381,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;
}
+40 -1
View File
@@ -15,6 +15,7 @@ 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');
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version:
* type: string
* description: sofarr version
* example: "1.7.22"
* example: "1.7.38"
* x-code-samples:
* - lang: curl
* label: cURL
@@ -232,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;
}
/**
+48 -5
View File
@@ -163,12 +163,14 @@ class OmbiRetriever extends ArrRetriever {
_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 = {
@@ -178,15 +180,56 @@ class OmbiRetriever extends ArrRetriever {
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
};
return {
result = {
...req,
requestedUser: hydratedUser,
RequestedUser: hydratedUser
};
}
}
return req;
// 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;
}
/**
+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;
+2 -287
View File
@@ -82,20 +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 debugRoutes = require('./routes/debug');
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
@@ -117,284 +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,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' }
});
// ---------------------------------------------------------------------------
// Body parsing & cookies
// ---------------------------------------------------------------------------
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// ---------------------------------------------------------------------------
// Health / readiness endpoints (no auth, no rate-limit)
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
/**
* @openapi
* /health:
* get:
* tags: [Health]
* summary: Health check
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
* security: []
* responses:
* '200':
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ok"
* uptime:
* type: number
* description: Server uptime in seconds
* example: 3600.5
* version:
* type: string
* description: sofarr version
* example: "1.7.22"
*/
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);
app.use('/api/debug', debugRoutes);
// 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.
+3 -3
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.22
version: 1.7.38
contact:
name: sofarr
license:
@@ -46,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
+27 -3
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');
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
ombiBaseUrl
});
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
res.json({
user: user.name,
isAdmin,
@@ -513,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(),
@@ -520,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,
+6 -61
View File
@@ -4,7 +4,7 @@ const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
const { applyRequestFilters } = require('../utils/ombiFilters');
const router = express.Router();
@@ -120,72 +120,17 @@ router.get('/requests', requireAuth, async (req, res) => {
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
// Fetch all series and movies in parallel to match
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: [] };
}
}))
]);
// For TV requests, find match in Sonarr
filteredTvRequests.forEach(req => {
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
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;
}
}
});
// For Movie requests, find match in Radarr
filteredMovieRequests.forEach(req => {
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;
}
}
});
}
// Tag with mediaType and flatten for filtering/sorting
const allRequests = [
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
];
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
await decorateRequestsWithArrLinks(allRequests, isAdmin);
}
// Parse query params
let types = req.query.type;
let statuses = req.query.status;
+11 -1
View File
@@ -7,6 +7,7 @@ const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
const downloadClientRegistry = require('../utils/downloadClients');
/**
* @openapi
@@ -165,7 +166,16 @@ router.get('/', requireAuth, async (req, res) => {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
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 });
+38 -32
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');
@@ -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;
@@ -202,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
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))`);
}
@@ -232,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
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))`);
}
@@ -480,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 });
}
@@ -634,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 });
}
@@ -813,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 || 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 });
}
+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);
+422 -440
View File
@@ -9,6 +9,199 @@
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;
}
/**
* Internal helper: Finds the best matching Sonarr or Radarr record for a SABnzbd slot.
* Performs robust case-insensitive downloadId matching (queue history),
* then bidirectional title fallback (queue history).
* This eliminates all duplication and asymmetry between matchSabSlots and matchSabHistory.
*
* @param {string|null} sabDownloadId
* @param {string} nzbName
* @param {Object} context
* @param {string} caller - e.g. 'matchSabHistory' or 'matchSabSlots'
* @returns {{ sonarrMatch: Object|null, radarrMatch: Object|null }}
*/
function findSabMatch(sabDownloadId, nzbName, context, caller = 'DownloadMatcher') {
const {
sonarrQueueRecords = [],
sonarrHistoryRecords = [],
radarrQueueRecords = [],
radarrHistoryRecords = []
} = context;
const findBest = (queueRecords, historyRecords) => {
// 1. Robust ID match (queue first)
let match = sabDownloadId
? queueRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
})
: null;
if (!match && sabDownloadId) {
match = historyRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
});
}
// 2. Title fallback (queue first, then history)
if (!match && nzbName) {
match = queueRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
if (!match && nzbName) {
match = historyRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
return match || null;
};
return {
sonarrMatch: findBest(sonarrQueueRecords, sonarrHistoryRecords),
radarrMatch: findBest(radarrQueueRecords, radarrHistoryRecords)
};
}
/**
* 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,310 +283,107 @@ 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 = [];
for (const slot of slots) {
const nzbName = slot.filename || slot.nzbname;
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name;
if (!nzbName) continue;
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
const nzbNameLower = nzbName.toLowerCase();
// Normalize SAB name (dots to spaces) for better matching
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
// Try to match by downloadId first (most reliable)
const sabDownloadId = slot.nzo_id || slot.id;
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabSlots');
// Also check HISTORY by downloadId
if (!sonarrMatch && sabDownloadId) {
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
}
if (!radarrMatch && sabDownloadId) {
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
}
// 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;
// Fallback: Check by title matching
if (!sonarrMatch) {
sonarrMatch = sonarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
// Also check HISTORY (completed downloads) if no queue match
if (!sonarrMatch) {
sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
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 series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
// Calculate progress from SABnzbd slot data
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
? parseFloat(slot.mbleft || slot.mbmissing)
: 0;
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
const dlObj = {
type: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
mbmissing: slot.mbleft,
size: Math.round(slot.mb * 1024 * 1024),
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
// Expose ARR IDs to non-admins for blocklist functionality
dlObj.arrQueueId = sonarrMatch.id;
dlObj.arrType = 'sonarr';
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
dlObj.arrContentId = sonarrMatch.episodeId || null;
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
dlObj.arrContentType = 'episode';
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
addOmbiMatching(dlObj, series, context);
matched.push(dlObj);
}
}
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;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
addOmbiMatching(dlObj, movie, context);
matched.push(dlObj);
}
}
const dlObj = buildArrDownload(radarrMatch, context, {
...commonOptions,
arrType: 'radarr'
});
if (dlObj) matched.push(dlObj);
}
}
return matched;
}
/**
* Matches SABnzbd history slots to Sonarr/Radarr activity using title matching.
* @param {Array} slots - SABnzbd history slots
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of matched download objects
*/
async function matchSabHistory(slots, context) {
const {
sonarrHistoryRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
ombiRetriever,
ombiBaseUrl
} = context;
const matched = [];
for (const slot of slots) {
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
if (!nzbName) continue;
const nzbNameLower = nzbName.toLowerCase();
const sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
for (const slot of slots) {
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name;
if (!nzbName) continue;
const sabDownloadId = slot.nzo_id || slot.id;
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory');
const commonOptions = {
title: nzbName,
status: slot.status || 'Completed',
progress: 100,
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 series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = {
type: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slot.status,
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
}
addOmbiMatching(dlObj, series, context);
matched.push(dlObj);
}
}
const dlObj = buildArrDownload(sonarrMatch, context, {
...commonOptions,
arrType: 'sonarr',
episodes: DownloadAssembler.gatherEpisodes(nzbName.toLowerCase(), context.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;
}
@@ -408,17 +398,7 @@ async function matchTorrents(torrents, context) {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
ombiRetriever,
ombiBaseUrl
radarrHistoryRecords
} = context;
const matched = [];
@@ -427,177 +407,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.arrContentIds = sonarrMatch.episodeIds || null;
download.arrSeriesId = sonarrMatch.seriesId || null;
download.arrContentType = 'episode';
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getSonarrLink(series);
download.arrInstanceKey = sonarrMatch._instanceKey || null;
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
addOmbiMatching(download, series, context);
matched.push(download);
matchedAny = true;
continue;
}
}
const 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;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.arrInstanceKey = radarrMatch._instanceKey || null;
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
addOmbiMatching(download, movie, context);
matched.push(download);
matchedAny = true;
continue;
}
}
const 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;
}
@@ -608,5 +588,7 @@ module.exports = {
addOmbiMatching,
matchSabSlots,
matchSabHistory,
matchTorrents
matchTorrents,
buildArrDownload,
matchOrphanedArrRecords
};
+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
};
+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
};
}
})
+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';
}
+154 -1
View File
@@ -80,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
};
+3 -22
View File
@@ -3,6 +3,7 @@ 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,
@@ -237,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 || [])
@@ -265,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
});
});
});
+128 -10
View File
@@ -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;
});
// ---------------------------------------------------------------------------
@@ -563,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');
});
});
});
// ---------------------------------------------------------------------------
@@ -1093,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();
});
});
@@ -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([]);
});
});
+42
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' },
+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');
});
});
+32
View File
@@ -387,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();
});
});
// ---------------------------------------------------------------------------
+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');
});
});
@@ -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');
});
});
// ---------------------------------------------------------------------------
+28
View File
@@ -130,6 +130,34 @@ describe('ombiHelpers', () => {
};
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([]);
});
});
});
+191
View File
@@ -145,4 +145,195 @@ 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);
});
it('falls back to title matching against Sonarr queue records when downloadId is absent/unmatched', async () => {
const testContext = {
...context,
sonarrHistoryRecords: [],
sonarrQueueRecords: [
{ id: 201, seriesId: 1, title: 'My.Cool.Show.S01E01.720p-Group' }
],
seriesMap: new Map([[1, { id: 1, title: 'My Cool Show', tags: [1] }]])
};
const slots = [{ id: null, name: 'My_Cool_Show_S01E01_720p-Group.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(201);
expect(result[0].arrType).toBe('sonarr');
});
it('falls back to title matching against Radarr queue records when downloadId is absent/unmatched', async () => {
const testContext = {
...context,
radarrHistoryRecords: [],
radarrQueueRecords: [
{ id: 301, movieId: 2, title: 'Awesome Movie 2026 1080p' }
],
moviesMap: new Map([[2, { id: 2, title: 'Awesome Movie', tags: [1] }]]),
radarrTagMap: new Map([[1, 'alice']])
};
const slots = [{ id: null, name: 'Awesome.Movie.2026.1080p.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(301);
expect(result[0].arrType).toBe('radarr');
});
it('matches when history slots only have the filename field (cached legacy format)', async () => {
const testContext = {
...context,
sonarrHistoryRecords: [],
sonarrQueueRecords: [
{ id: 201, seriesId: 1, title: 'My.Cool.Show.S01E01.720p-Group' }
],
seriesMap: new Map([[1, { id: 1, title: 'My Cool Show', tags: [1] }]])
};
const slots = [{ id: null, filename: 'My_Cool_Show_S01E01_720p-Group.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(201);
expect(result[0].arrType).toBe('sonarr');
});
});
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,