Compare commits

..

36 Commits

Author SHA1 Message Date
gronod eeab314a08 chore: bump version to 1.5.1
Build and Push Docker Image / build (push) Successful in 43s
Create Release / release (push) Successful in 17s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m31s
2026-05-19 19:07:05 +01:00
gronod 603f444c33 fix(webhooks): mount webhook routes in index.js before verifyCsrf
Webhook routes were only registered in app.js (the test factory) but
not in index.js (the production entry point). POST /api/webhook/*
was therefore falling through to the verifyCsrf middleware and being
rejected with 403 in production.
2026-05-19 19:06:36 +01:00
gronod 917939a9fc fix(ui): wire status panel close button via addEventListener
CI / Security audit (push) Failing after 29s
Docs Check / Markdown lint (push) Successful in 47s
Build and Push Docker Image / build (push) Successful in 1m3s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m39s
Docs Check / Mermaid diagram parse check (push) Successful in 1m54s
Inline onclick attribute was silently blocked by the server CSP nonce
policy. Replace with addEventListener after innerHTML is set.

chore: bump version to 1.5.0a
2026-05-19 18:51:50 +01:00
gronod 3747dab36f Merge branch 'develop-webhook-receiver' into develop
Docs Check / Markdown lint (push) Successful in 45s
Build and Push Docker Image / build (push) Successful in 1m5s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m11s
CI / Security audit (push) Successful in 1m47s
CI / Tests & coverage (push) Successful in 2m1s
Docs Check / Mermaid diagram parse check (push) Successful in 2m23s
2026-05-19 18:33:07 +01:00
gronod 76f0aad453 chore: bump version to 1.5.0
Build and Push Docker Image / build (push) Successful in 50s
Docs Check / Markdown lint (push) Successful in 41s
CI / Security audit (push) Successful in 1m35s
CI / Tests & coverage (push) Successful in 1m54s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m7s
Docs Check / Mermaid diagram parse check (push) Successful in 1m28s
2026-05-19 18:33:03 +01:00
gronod 67ab378d31 docs: merge ARCHITECTURE.md files into single consolidated reference
Build and Push Docker Image / build (push) Successful in 43s
Docs Check / Markdown lint (push) Successful in 47s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Has been cancelled
- Combine root ARCHITECTURE.md (webhook/smart-polling focused) with
  docs/ARCHITECTURE.md (deep-dive) into one authoritative document
- Structured into 11 sections: Introduction, High-Level Architecture,
  Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data
  Flow, Caching & Smart Polling, Key Subsystems, Directory Structure,
  Configuration, Security Model, Technology Stack
- Add full-system Mermaid flowchart, webhook sequence diagram, polling
  cycle sequence diagram, UI state machine, download matching flowchart
- Document all cache keys, NormalizedDownload schema, DownloadClientRegistry
  and arrRetrieverRegistry APIs, webhook event classification table,
  complete security model with auth/webhook/headers subsections
- Remove all development-phase references and internal process language
- Remove docs/ARCHITECTURE.md (content consolidated into root file)
2026-05-19 18:32:00 +01:00
gronod 1bef14d590 feat(webhooks): security hardening, tests, full documentation audit & polish (Phase 6)
Build and Push Docker Image / build (push) Successful in 41s
Docs Check / Markdown lint (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m43s
2026-05-19 17:11:45 +01:00
gronod 8609f03c5a fix(webhooks): connect receiver to cache metrics for polling optimization (Phase 5.1)
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m16s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Successful in 1m34s
2026-05-19 16:41:39 +01:00
gronod fcb0cd8e4a feat(webhooks): add polling optimization and fallback when webhooks are active (Phase 5)
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m32s
2026-05-19 16:10:45 +01:00
gronod 80e8b72878 feat(webhooks): add simple frontend webhook configuration UI (Phase 4)
Build and Push Docker Image / build (push) Successful in 26s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m32s
CI / Tests & coverage (push) Successful in 4m1s
2026-05-19 15:52:44 +01:00
gronod e022db8ef5 feat(webhooks): add notification management API + one-click Sofarr webhook setup (Phase 3)
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-19 15:31:50 +01:00
gronod 1d61ea8d83 feat(webhooks): integrate receiver with cache + SSE (Phase 2)
CI / Security audit (push) Failing after 16s
Build and Push Docker Image / build (push) Successful in 32s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 49s
CI / Tests & coverage (push) Successful in 1m19s
2026-05-19 15:24:43 +01:00
gronod 99ddb05dbe feat(webhook): implement Phase 1 webhook receiver for Sonarr and Radarr
Build and Push Docker Image / build (push) Successful in 1m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m15s
CI / Security audit (push) Successful in 1m44s
CI / Tests & coverage (push) Successful in 1m53s
- Added POST /api/webhook/sonarr and POST /api/webhook/radarr endpoints
- Implemented webhook secret validation via SOFARR_WEBHOOK_SECRET environment variable
- Added logging for all incoming webhook events using existing logToFile utility
- Returns HTTP 200 immediately to prevent webhook retries
- Mounted webhook routes before CSRF middleware (called by external services)
- Non-breaking: no changes to polling, caching, SSE, or any existing behavior
- Lays groundwork for Phase 2 (cache + SSE integration) without implementing it yet
2026-05-19 15:15:53 +01:00
gronod 934f5e3fd5 Merge branch 'develop-paldra' into develop
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 54s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m20s
refactor(arr-retrievers): implement Pluggable *arr Retrieval Layer (PALDRA) (#19)

- Added abstract ArrRetriever base class and concrete PollingSonarrRetriever / PollingRadarrRetriever
- Created centralized ArrRetrieverRegistry (pure singleton, matching PDCA style)
- Refactored poller.js and historyFetcher.js to use the new pluggable registry
- 100% backward compatible: no changes to behavior, caching, SSE, performance, or APIs

This completes the PALDRA work from ticket #19 and lays the groundwork for webhook support.
2026-05-19 15:10:17 +01:00
gronod 21befa5356 chore: align version with develop branch (1.4.0)
Build and Push Docker Image / build (push) Successful in 1m12s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
CI / Security audit (push) Successful in 1m27s
CI / Tests & coverage (push) Successful in 1m38s
2026-05-19 15:01:15 +01:00
gronod 6529702f73 chore: bump version to 1.4.0 2026-05-19 14:58:37 +01:00
gronod 6e199925aa refactor: make PALDRA match PDCA style exactly - remove redundant instanceConfig parameter and convert to pure singleton
Build and Push Docker Image / build (push) Successful in 20s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 54s
- Remove instanceConfig parameter from all retriever methods (getTags, getQueue, getHistory)
- Retriever instances now use this.url, this.apiKey, this.id instead of passed parameter
- Convert ArrRetrieverRegistry from class with convenience functions to pure singleton object
- Export singleton instance directly instead of class + convenience functions
- Update poller.js and historyFetcher.js to call methods on singleton directly
- All 261 tests pass with zero behavior changes
2026-05-19 14:51:22 +01:00
gronod 627329df2f feat: implement Pluggable Abstraction Layer for Data Retrieval (PALDRA) - #19
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 46s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m35s
- Create ArrRetriever abstract base class defining pluggable interface
- Implement PollingSonarrRetriever and PollingRadarrRetriever with HTTP polling
- Add ArrRetrieverRegistry for managing retriever instances
- Refactor poller.js to use retriever registry instead of direct Axios calls
- Update historyFetcher.js to use retriever registry
- Preserve all cache keys, TTLs, timing logs, SSE broadcasts, error handling
- Enable future webhook listeners without touching poller logic
2026-05-19 14:43:28 +01:00
Gandalf fa0e9a93af Merge pull request 'Merge branch 'develop-pdca' into develop' (#20) from develop-pdca into develop
Build and Push Docker Image / build (push) Successful in 51s
Docs Check / Markdown lint (push) Successful in 1m1s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m42s
CI / Security audit (push) Successful in 1m46s
Docs Check / Mermaid diagram parse check (push) Successful in 1m53s
Reviewed-on: #20
2026-05-19 14:35:32 +01:00
gronod 9343486705 Fix all Vitest test failures after migration
Build and Push Docker Image / build (push) Successful in 24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Successful in 1m25s
Docs Check / Markdown lint (pull_request) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 1m14s
CI / Security audit (pull_request) Successful in 1m33s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m56s
CI / Tests & coverage (pull_request) Successful in 2m3s
- Replace vi.mock('axios') with nock for HTTP request mocking (ES/CJS interop issue)
- Fix RTorrentClient by mocking client.client.methodCall directly instead of xmlrpc module
- Fix downloadClients.test.js by manually adding mock clients to registry
- Fix qbittorrent.test.js to use getActiveDownloads() and normalized properties
- Fix integration test env var mocks and error assertions
- Fix SABnzbdClient size parsing and test fixtures
- Fix RTorrentClient ETA calculation expectation

All 261 tests now passing.
2026-05-19 13:53:09 +01:00
gronod 5342170ced fix: convert test files to ES modules and fix qbittorrent test method calls
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m3s
CI / Tests & coverage (push) Failing after 1m18s
- Convert all client test files from CommonJS require() to ES module import syntax
- Convert downloadClients.test.js and integration/downloadClients.test.js to ES modules
- Fix qbittorrent.test.js to use getActiveDownloads() instead of getTorrents()
- All test files now use proper Vitest-compatible ES module syntax
- Resolves Vitest import errors and QBittorrentClient method call errors
2026-05-19 12:19:04 +01:00
gronod cc0e34b3d1 fix: convert all test files from jest to vitest and fix QBittorrentClient import
CI / Security audit (push) Failing after 19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
Build and Push Docker Image / build (push) Successful in 1m10s
CI / Tests & coverage (push) Failing after 1m21s
- Convert RTorrentClient.test.js to use vi.mock() instead of jest.mock()
- Convert QBittorrentClient.test.js to use vi.mock() instead of jest.mock()
- Convert SABnzbdClient.test.js to use vi.mock() instead of jest.mock()
- Convert TransmissionClient.test.js to use vi.mock() instead of jest.mock()
- Convert downloadClients.test.js to use vi.mock() instead of jest.mock()
- Convert integration/downloadClients.test.js to use vi.mock() instead of jest.mock()
- Fix legacy qbittorrent.test.js to import QBittorrentClient from new location
- Add getRtorrentInstances mock to downloadClients.test.js
- Add RTORRENT_INSTANCES to integration test environment variables
2026-05-19 12:12:44 +01:00
gronod e39f15d3d8 fix: update package-lock.json after adding xmlrpc dependency
Build and Push Docker Image / build (push) Successful in 1m20s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m16s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Failing after 1m38s
2026-05-19 12:02:23 +01:00
gronod bbcbf8d0f7 docs: polish rtorrent URL path documentation to exact specifications
Build and Push Docker Image / build (push) Failing after 33s
CI / Tests & coverage (push) Failing after 37s
CI / Security audit (push) Failing after 44s
Docs Check / Markdown lint (push) Successful in 47s
Docs Check / Mermaid diagram parse check (push) Successful in 1m36s
- Update .env.sample RTORRENT_INSTANCES section with exact comment format
- Update README.md rTorrent table row with specific endpoint note
- Add explicit "No path is automatically appended" statement in README
- RTorrentClient.js already uses exact URL from config (no changes needed)
2026-05-19 11:58:42 +01:00
gronod 620f264861 fix: remove auto-appending of /RPC2 from RTorrentClient and finalize PDCA documentation
Build and Push Docker Image / build (push) Failing after 40s
CI / Security audit (push) Failing after 45s
CI / Tests & coverage (push) Failing after 55s
Docs Check / Markdown lint (push) Successful in 1m1s
Docs Check / Mermaid diagram parse check (push) Successful in 1m23s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 28s
- Remove auto-appending of /RPC2 from RTorrentClient constructor
- Use exact URL from config (supports custom paths like whatbox.ca/xmlrpc)
- Update .env.sample with clear URL path documentation and examples
- Update README.md with comprehensive PDCA section and all download clients
- Add URL path verification tests (whatbox.ca, custom paths, no auth)
- Update architecture diagram to include Transmission and rTorrent
- Update Docker Compose example to include all download clients
- Update prerequisites to mention all supported download clients
- Update "What It Does" and "The Matching Process" sections
2026-05-19 11:53:51 +01:00
gronod a50e5a7d69 feat: add rtorrent client via PDCA
Build and Push Docker Image / build (push) Failing after 40s
CI / Security audit (push) Failing after 27s
CI / Tests & coverage (push) Failing after 35s
Docs Check / Markdown lint (push) Successful in 32s
Docs Check / Mermaid diagram parse check (push) Successful in 1m12s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 24s
- Implement RTorrentClient extending DownloadClient abstract class
- Use xmlrpc package (v1.3.2) for XML-RPC communication
- Support HTTP Basic Auth when credentials are configured
- Map rTorrent states (d.state, d.is_active, d.is_hash_checking) to normalized statuses
- Calculate ETA from download speed and remaining bytes
- Add getRtorrentInstances() to config.js
- Register RTorrentClient in downloadClients.js registry
- Add 8 comprehensive unit tests covering all functionality
- Update .env.sample with rtorrent configuration examples
- Update ARCHITECTURE.md with rtorrent client details
- Update ADDING-A-DOWNLOAD-CLIENT.md with rtorrent-specific notes
2026-05-19 11:40:31 +01:00
gronod f095e6a2d1 Fix QBittorrentClient export in legacy qbittorrent.js
Build and Push Docker Image / build (push) Successful in 44s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Failing after 1m22s
Remove undefined QBittorrentClient export that was causing
container startup failures. The actual implementation is now
in server/clients/QBittorrentClient.js
2026-05-19 11:21:31 +01:00
gronod bf3e1c353d Implement Pluggable Download Client Architecture (PDCA)
Build and Push Docker Image / build (push) Successful in 31s
Docs Check / Markdown lint (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m39s
CI / Security audit (push) Successful in 1m49s
Docs Check / Mermaid diagram parse check (push) Successful in 1m56s
- Add abstract DownloadClient base class with standardized interface
- Refactor QBittorrentClient to extend DownloadClient with Sync API support
- Create SABnzbdClient implementing DownloadClient interface
- Add TransmissionClient as proof-of-concept implementation
- Implement DownloadClientRegistry for factory pattern and client management
- Refactor poller.js to use unified client interface (30-40% code reduction)
- Maintain 100% backward compatibility with existing cache structure
- Add comprehensive test suite (12 unit + integration tests)
- Update ARCHITECTURE.md with detailed PDCA documentation
- Create ADDING-A-DOWNLOAD-CLIENT.md guide for future client additions

Features:
- Client-agnostic polling with error isolation
- Consistent data normalization across all clients
- Easy extensibility for new download client types
- Zero breaking changes to existing functionality
- Parallel execution with unified timing and logging
2026-05-19 11:18:19 +01:00
gronod c85ff602d0 ci: use develop* glob in build-image branch trigger
Build and Push Docker Image / build (push) Successful in 28s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 1m24s
Docs Check / Markdown lint (push) Successful in 37s
Docs Check / Mermaid diagram parse check (push) Successful in 1m9s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 44s
2026-05-19 09:47:26 +01:00
gronod d73e1dcf0b ci: build Docker images on develop* branches
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m15s
2026-05-19 09:37:43 +01:00
gronod 0a54d0d302 refactor: use qBittorrent Sync API (/api/v2/sync/maindata) with fallback
Docs Check / Markdown lint (push) Successful in 51s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m16s
CI / Tests & coverage (push) Successful in 1m37s
CI / Security audit (push) Successful in 1m44s
Docs Check / Mermaid diagram parse check (push) Successful in 1m52s
- QBittorrentClient now uses the incremental Sync API instead of repeatedly
  fetching the full torrent list via /api/v2/torrents/info.
- Per-client state: lastRid, torrentMap, fallbackThisCycle.
- Handles full_update, delta updates, and torrents_removed.
- Falls back to legacy torrents/info at most once per poll cycle.
- getAllTorrents() resets fallback flags before each cycle.
- Added 9 new unit tests covering: first sync, delta merge, full_update,
  torrents_removed, fallback path, direct-legacy-after-fallback, 403 re-auth,
  completed-field computation, and fallback reset.
2026-05-19 09:33:20 +01:00
gronod 8c4cc20551 Add MIT copyright headers to all source files
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m21s
CI / Security audit (push) Successful in 1m47s
CI / Tests & coverage (push) Successful in 2m1s
2026-05-19 09:07:42 +01:00
Gandalf da77f083fe Merge pull request 'Update .gitea/workflows/licence-check.yml' (#17) from develop-workflow into develop
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 54s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Successful in 1m39s
Reviewed-on: #17
2026-05-18 13:45:03 +01:00
Gandalf 71feaf0175 Update .gitea/workflows/licence-check.yml
Licence Check / Licence compatibility and copyright header verification (push) Failing after 56s
CI / Security audit (push) Successful in 1m7s
CI / Tests & coverage (push) Successful in 1m18s
Licence Check / Licence compatibility and copyright header verification (pull_request) Failing after 39s
CI / Security audit (pull_request) Successful in 1m13s
CI / Tests & coverage (pull_request) Successful in 1m23s
2026-05-18 13:39:59 +01:00
Gandalf b41f943407 fix: Remove reference to PlantUML diagrams
Build and Push Docker Image / build (push) Successful in 1m3s
CI / Security audit (pull_request) Successful in 1m31s
CI / Tests & coverage (pull_request) Successful in 1m39s
Docs Check / Markdown lint (push) Failing after 18s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Successful in 1m48s
2026-05-18 07:47:56 +01:00
gronod 9debd77392 docs: update ARCHITECTURE.md - fix CI/CD table, remove stale diagram refs, update data models
Build and Push Docker Image / build (push) Successful in 45s
Docs Check / Markdown lint (push) Successful in 1m2s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Successful in 1m41s
Docs Check / Mermaid diagram parse check (push) Successful in 1m48s
- CI/CD table: add docs-check.yml and licence-check.yml, correct build-image.yml trigger (release/** + develop, not main)
- Section 13 intro: clarify no PNG exports or external tooling required
- Download class diagram: add canBlocklist, addedOn, arrQueueId, arrType, arrInstanceUrl, arrContentId, arrContentType fields
- qBittorrentTorrent class diagram: add added_on field
- Remove docs/diagrams/ directory (PNG exports superseded by embedded Mermaid)
2026-05-18 07:44:41 +01:00
77 changed files with 7611 additions and 1844 deletions
+46
View File
@@ -19,6 +19,36 @@ LOG_LEVEL=info
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here COOKIE_SECRET=your-cookie-secret-here
# =============================================================================
# WEBHOOK SETTINGS
# =============================================================================
# Secret for validating incoming webhooks from Sonarr and Radarr
# Required for webhook endpoints to accept requests
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
# Generate with: openssl rand -hex 32
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Public base URL of Sofarr (for webhook configuration)
# Required for the one-click webhook setup endpoints
# Sonarr/Radarr need this URL to know where to send webhook events
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# --- Webhook Polling Optimization (Phase 5) ---
# Minutes of silence after which the poller falls back to a full poll
# even if webhooks were recently active. Default: 10 minutes.
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
# reduce background polling on very stable setups.
# WEBHOOK_FALLBACK_TIMEOUT=10
# When an instance has received a recent webhook event, the poller skips
# its queue/history fetch entirely (saving API calls). If you still want
# a periodic poll even with webhooks, set this to 1 to disable skipping.
# Default behaviour: skip polling for instances with recent webhook activity.
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
# ============================================================================= # =============================================================================
# TLS / HTTPS # TLS / HTTPS
# ============================================================================= # =============================================================================
@@ -94,6 +124,17 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
# QBITTORRENT_USERNAME=admin # QBITTORRENT_USERNAME=admin
# QBITTORRENT_PASSWORD=your-password # QBITTORRENT_PASSWORD=your-password
# =============================================================================
# RTORRENT_INSTANCES (JSON Array)
# The url MUST include the full XML-RPC endpoint path.
# Standard/self-hosted installs: .../RPC2
# whatbox.ca users: .../xmlrpc
# Other installations may use different custom paths.
# Example:
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent.local:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca:
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# ============================================================================= # =============================================================================
# SONARR INSTANCES (JSON Array Format) # SONARR INSTANCES (JSON Array Format)
# Add one or more Sonarr instances as a single-line JSON array # Add one or more Sonarr instances as a single-line JSON array
@@ -125,4 +166,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# 4. For qBittorrent, ensure Web UI is enabled in settings # 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media! # 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups # 6. Background polling keeps data fresh; disable it for low-resource setups
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
# push updates from Sonarr/Radarr and automatically reduce polling load.
# Use the Webhooks Configuration panel in the dashboard UI to enable them
# with one click. The secret must match the header value in each *arr
# notification connection (X-Sofarr-Webhook-Secret).
# ============================================================================= # =============================================================================
+6 -4
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- 'release/**' - 'release/**'
- 'develop' - 'develop*'
jobs: jobs:
build: build:
@@ -20,9 +20,11 @@ jobs:
BRANCH=${GITHUB_REF#refs/heads/} BRANCH=${GITHUB_REF#refs/heads/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [ "$BRANCH" = "develop" ]; then if [[ "$BRANCH" == develop* ]]; then
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT # Sanitise branch name for tag: replace slashes with dashes
echo "Building develop image (version ${VERSION})" SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
else else
RELEASE_NAME=${BRANCH#release/} RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}" TAGS="reg.i3omb.com/sofarr:${VERSION}"
+46 -1
View File
@@ -7,16 +7,24 @@ on:
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
- ".gitea/workflows/licence-check.yml" - ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
pull_request: pull_request:
branches: ["**", "!main", "!release/**"] branches: ["**", "!main", "!release/**"]
paths: paths:
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
- ".gitea/workflows/licence-check.yml" - ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
jobs: jobs:
licence-check: licence-check:
name: Dependency licence compatibility name: Licence compatibility and copyright header verification
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
steps: steps:
@@ -36,3 +44,40 @@ jobs:
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \ --onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
--excludePrivatePackages \ --excludePrivatePackages \
&& echo "All production dependency licences are compatible with MIT." && echo "All production dependency licences are compatible with MIT."
- name: Check copyright headers in source files
run: |
#!/bin/bash
set -e
# Find all source files, excluding build artifacts and node_modules
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
! -path "./node_modules/*" \
! -path "./.git/*" \
! -path "./dist/*" \
! -path "./build/*" \
! -path "./.gitea/*")
MISSING_HEADER=0
# Check each file for MIT-compliant copyright header
while IFS= read -r file; do
if [ -z "$file" ]; then
continue
fi
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
echo "❌ Missing MIT-compliant copyright header in: $file"
echo " Required format: // Copyright (c) YYYY Name. MIT License."
MISSING_HEADER=$((MISSING_HEADER + 1))
fi
done <<< "$SOURCE_FILES"
if [ $MISSING_HEADER -gt 0 ]; then
echo ""
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
exit 1
else
echo "✅ All source files have MIT-compliant copyright headers."
fi
+927
View File
@@ -0,0 +1,927 @@
# sofarr — Architecture
Comprehensive technical reference for the **sofarr** application: a personal media download dashboard that aggregates download activity from SABnzbd, Sonarr, Radarr, qBittorrent, Transmission, and rTorrent, filters results by Emby/Jellyfin user identity, and presents a real-time personalised dashboard.
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [High-Level Architecture](#2-high-level-architecture)
3. [Pluggable Architecture Layers](#3-pluggable-architecture-layers)
4. [Webhook System](#4-webhook-system)
5. [Data Flow and Real-time Updates](#5-data-flow-and-real-time-updates)
6. [Caching and Smart Polling](#6-caching-and-smart-polling)
7. [Key Subsystems](#7-key-subsystems)
8. [Directory Structure](#8-directory-structure)
9. [Configuration and Environment Variables](#9-configuration-and-environment-variables)
10. [Security Model](#10-security-model)
11. [Technology Stack](#11-technology-stack)
---
## 1. Introduction
sofarr is a **Node.js/Express single-page application** that provides a personalised view of media downloads. It:
1. **Authenticates** users against an Emby/Jellyfin media server.
2. **Aggregates** download data from multiple *arr service instances and download clients.
3. **Filters** downloads per user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presents** a real-time dashboard with progress, speeds, cover art, and status, updated either via background polling or instant webhook push from Sonarr/Radarr.
Admin users can view all users' downloads, see server status, cache statistics, poll timings, and perform blocklist-and-search operations.
Three pluggable layers form the architectural core:
| Layer | Name | Location |
|-------|------|----------|
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
---
## 2. High-Level Architecture
```mermaid
flowchart TB
subgraph Browser["Browser (SPA — public/)"]
login["Login Form"]
dash["Dashboard Cards"]
status["Status Panel\n(Admin only)"]
history["History Tab"]
end
subgraph Server["Express Server (:3001)"]
direction TB
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
auth_r["Auth Routes\n/api/auth"]
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
hist_r["History Routes\n/api/history"]
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
subgraph Core["Core Utilities"]
poller["Poller\n(smart background polling)"]
cache["MemoryCache\n(poll:* + webhook metrics)"]
pdca["PDCA Registry\n(download clients)"]
paldra["PALDRA Registry\n(arr retrievers)"]
tokenstore["TokenStore\n(tokens.json)"]
end
end
subgraph Ext["External Services"]
sab["SABnzbd"]
sonarr["Sonarr"]
radarr["Radarr"]
qbt["qBittorrent"]
rtorrent["rTorrent"]
transmission["Transmission"]
emby["Emby / Jellyfin"]
end
login -->|"POST /api/auth/login"| auth_r
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
status -->|"GET /api/dashboard/status"| dash_r
history -->|"GET /api/history/recent"| hist_r
auth_r --> tokenstore
auth_r -->|"authenticate"| emby
dash_r --> cache
dash_r --> poller
wh_r --> cache
wh_r --> paldra
hist_r --> cache
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
poller --> pdca & paldra
poller --> cache
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
paldra -->|"HTTP/API"| sonarr & radarr
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
```
### Request routing summary
```
Browser (SPA)
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
│ GET /api/dashboard/stream → SSE stream → cache → matched downloads
│ POST /api/webhook/* ← Sonarr/Radarr push events
Express Server (:3001)
├── Helmet (CSP nonce, HSTS, X-Frame-Options, …)
├── express-rate-limit (300/15 min general; 60/1 min webhook; 10 fails/15 min login)
├── cookie-parser (HMAC-signed session cookie)
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
├── /api/auth → login, logout, me, csrf
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
Background:
Poller (setInterval POLL_INTERVAL ms)
└── shouldSkipInstancePolling? ──yes──► extend cache TTL, increment pollsSkipped
│ no (or fallback triggered)
PDCA Registry.getDownloadsByClientType()
PALDRA Registry.getQueuesByType() / getHistoryByType() / getTagsByType()
cache.set('poll:*', data, TTL)
notify pollSubscribers → SSE push to all connected browsers
```
---
## 3. Pluggable Architecture Layers
### 3.1 Pluggable Download Client Architecture (PDCA)
#### Overview
The PDCA provides a unified, extensible interface for all download clients. This abstraction layer enables:
- **Client-agnostic polling** — the poller contains no client-specific logic.
- **Easy extension** — add a new client by implementing one interface.
- **Consistent normalisation** — all clients return standardised download objects.
- **Centralised configuration** — a single registry manages all instances.
- **Error isolation** — individual client failures do not affect other clients.
#### Abstract Base Class
All download clients extend `DownloadClient` (`server/clients/DownloadClient.js`):
```javascript
class DownloadClient {
constructor(instanceConfig)
getClientType(): string
getInstanceId(): string
async testConnection(): Promise<boolean>
async getActiveDownloads(): Promise<NormalizedDownload[]>
async getClientStatus(): Promise<Object|null> // optional
normalizeDownload(download): NormalizedDownload
}
```
#### Client Implementations
```
DownloadClient (abstract)
├── SABnzbdClient — REST API, API key auth; handles queue + history; normalises time/size units
├── QBittorrentClient — Sync API (incremental deltas), cookie auth, fallback to /torrents/info
├── TransmissionClient — JSON-RPC, session-ID management
└── RTorrentClient — XML-RPC (xmlrpc 1.3.2), HTTP Basic Auth; maps rTorrent states to normalised statuses
```
#### Normalised Download Schema
Every client returns objects conforming to this schema:
```javascript
interface NormalizedDownload {
id: string // Client-specific unique ID
title: string // Download title/name
type: 'usenet' | 'torrent' // Download type
client: string // Client identifier ('sabnzbd', 'qbittorrent', etc.)
instanceId: string // Instance identifier
instanceName: string // Instance display name
status: string // Normalised status (Downloading, Seeding, etc.)
progress: number // Progress percentage (0100)
size: number // Total size in bytes
downloaded: number // Downloaded bytes
speed: number // Current speed in bytes/sec
eta: number | null // ETA in seconds, null if unknown
category?: string // Download category (optional)
tags?: string[] // Download tags (optional)
savePath?: string // Save path (optional)
addedOn?: string // Added timestamp (optional)
arrQueueId?: number // Sonarr/Radarr queue ID (optional)
arrType?: 'series' | 'movie' // Sonarr/Radarr type (optional)
raw?: any // Original client response (escape hatch)
}
```
#### Registry (`server/utils/downloadClients.js`)
`DownloadClientRegistry` manages all instances:
```javascript
class DownloadClientRegistry {
async initialize() // Create clients from config
getAllClients(): DownloadClient[]
getClient(instanceId): DownloadClient
getClientsByType(type): DownloadClient[]
async getAllDownloads(): NormalizedDownload[] // Fetch from all clients in parallel
async testAllConnections(): Promise<ConnectionTestResult[]>
async getAllClientStatuses(): Promise<ClientStatus[]>
}
```
**Configuration-driven:** reads from `*_INSTANCES` environment variables (JSON array format) with fallback to legacy `*_URL` / `*_API_KEY` / `*_USERNAME` / `*_PASSWORD` variables.
#### qBittorrent Sync API Details
Each `QBittorrentClient` instance maintains:
- **`lastRid`** — response ID from the previous `sync/maindata` call (starts at `0`).
- **`torrentMap`** — `Map<hash, torrent>` holding the complete state for every known torrent.
- **`fallbackThisCycle`** — boolean tracking whether this poll cycle has already fallen back to the legacy endpoint.
Per-cycle flow:
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
2. If `full_update` is `true`, rebuild `torrentMap` from scratch.
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
5. If the fallback also fails, return an empty array for this cycle and log the error.
The rest of the application (poller, dashboard) receives data in the same format regardless of which path was taken.
#### Adding a New Download Client
1. Create `server/clients/MyClient.js` extending `DownloadClient`.
2. Implement `getActiveDownloads()` returning `NormalizedDownload[]`.
3. Register the class in the registry factory inside `server/utils/downloadClients.js`.
---
### 3.2 Pluggable *arr Retrieval Layer (PALDRA)
#### Overview
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever` or `PollingRadarrRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
#### Registry API
```javascript
arrRetrieverRegistry = {
async initialize() // idempotent; reads config once
getAllRetrievers(): ArrRetriever[]
getRetriever(instanceId): ArrRetriever | null
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr'
// Typed fetch methods — all return { sonarr: [...], radarr: [...] }
async getQueuesByType(): Promise<{ sonarr, radarr }>
async getHistoryByType(options?): Promise<{ sonarr, radarr }>
async getTagsByType(): Promise<{ sonarr, radarr }>
}
```
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
#### Retriever API Calls
| Task | Endpoint | Key Parameters |
|------|----------|----------------|
| Sonarr tags | `GET /api/v3/tag` | — |
| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
| Sonarr history | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
| Radarr tags | `GET /api/v3/tag` | — |
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr history | `GET /api/v3/history` | `pageSize=10` |
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others.
---
## 4. Webhook System
### 4.1 Webhook Receiver
sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event:
```
POST /api/webhook/sonarr
POST /api/webhook/radarr
```
Both endpoints share identical processing logic:
```
Sonarr/Radarr
POST /api/webhook/sonarr
Headers: X-Sofarr-Webhook-Secret: <secret>
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
"date": "2026-05-19T10:00:00.000Z", … }
webhookLimiter (60 req/min/IP)
validateWebhookSecret() ──fail──► 401 Unauthorized
│ ok
validatePayload() ──fail──► 400 Bad Request
│ ok
isReplay() ──yes───► 200 { received: true, duplicate: true }
│ no
cache.updateWebhookMetrics(instance.url) ← activates smart polling skip
200 { received: true } ← response sent immediately
▼ (fire-and-forget)
processWebhookEvent(serviceType, eventType)
├── classify: QUEUE_EVENT or HISTORY_EVENT
├── arrRetrieverRegistry.getQueuesByType() / getHistoryByType()
├── cache.set('poll:sonarr-queue' | 'poll:sonarr-history', …, CACHE_TTL)
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
```
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
#### Event Classification
| Event type | Classification | Cache keys refreshed |
|------------|---------------|---------------------|
| `Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired` | `QUEUE_EVENT` | `poll:{type}-queue` |
| `DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`, `EpisodeFileRenamedBySeries` | `HISTORY_EVENT` | `poll:{type}-history` |
| `Test`, `Rename`, `SeriesAdd`, `SeriesDelete`, `MovieAdd`, `MovieDelete`, `MovieFileDelete`, `Health`, `ApplicationUpdate`, `HealthRestored` | Informational — no refresh | — |
#### Accepted Event Types
The full allowlist enforced by `validatePayload()`:
```
Test · Grab · Download · DownloadFailed · ManualInteractionRequired
DownloadFolderImported · ImportFailed
EpisodeFileRenamed · MovieFileRenamed · EpisodeFileRenamedBySeries
Rename · SeriesAdd · SeriesDelete · MovieAdd · MovieDelete · MovieFileDelete
Health · ApplicationUpdate · HealthRestored
```
Any `eventType` not in this set is rejected with `400 Bad Request`.
---
### 4.2 Real-time Cache and SSE Integration
When a webhook event is classified as a `QUEUE_EVENT` or `HISTORY_EVENT`:
1. `arrRetrieverRegistry` fetches fresh data from the relevant *arr instances (in parallel, via PALDRA).
2. The result is written directly into the shared `MemoryCache` under the same `poll:*` key the poller uses — ensuring both paths produce identical cache shapes.
3. `pollAllServices()` is called, which iterates `pollSubscribers` and pushes the updated payload to every open SSE connection immediately.
The dashboard therefore receives fresh data within the round-trip time of the *arr API call, without waiting for the next poll cycle.
---
### 4.3 Notification Management API
The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL.
---
## 5. Data Flow and Real-time Updates
### 5.1 Polling Cycle (background path)
Every `POLL_INTERVAL` ms the poller fetches all services in parallel:
| Task | API | Key parameters |
|------|-----|----------------|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
| Sonarr Tags | `GET /api/v3/tag` | — |
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
| Radarr Tags | `GET /api/v3/tag` | — |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback: `GET /api/v2/torrents/info` |
Results are stored in `MemoryCache` under `poll:*` keys with TTL `POLL_INTERVAL × 3`. Per-task timings are recorded in `lastPollTimings` for the admin status panel.
```mermaid
sequenceDiagram
participant Entry as index.js
participant Poller
participant PDCA as PDCA Registry
participant PALDRA as PALDRA Registry
participant Cache as MemoryCache
participant SSE as SSE Subscribers
Entry->>Poller: startPoller()
loop Every POLL_INTERVAL ms
Poller->>Poller: polling flag check (skip if concurrent)
Poller->>PDCA: getDownloadsByClientType()
Poller->>PALDRA: getQueuesByType() / getHistoryByType() / getTagsByType()
PDCA-->>Poller: { sabnzbd, qbittorrent, rtorrent, transmission }
PALDRA-->>Poller: { sonarr: [...], radarr: [...] }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
Poller->>SSE: notify all subscribers → push data: frame
end
```
### 5.2 Webhook Path (real-time update)
```mermaid
sequenceDiagram
participant Arr as Sonarr/Radarr
participant WH as /api/webhook/sonarr
participant Cache as MemoryCache
participant PALDRA as PALDRA Registry
participant SSE as SSE Subscribers
Arr->>WH: POST /api/webhook/sonarr { eventType, instanceName, date }
WH->>WH: validateSecret + validatePayload + isReplay
WH->>Cache: updateWebhookMetrics(instance.url)
WH-->>Arr: 200 { received: true }
Note over WH: fire-and-forget begins
WH->>PALDRA: getQueuesByType() or getHistoryByType()
PALDRA-->>WH: fresh arr data
WH->>Cache: set poll:sonarr-queue / poll:sonarr-history
WH->>SSE: pollAllServices() → push data: frame to all clients
```
### 5.3 SSE Stream
When a browser opens `GET /api/dashboard/stream`:
1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`).
2. Immediately builds and sends the first payload (same matching logic as `/user-downloads`).
3. Registers a callback with the poller's `onPollComplete` subscriber set.
4. After every subsequent poll cycle (or webhook-triggered broadcast), the callback fires, rebuilds the payload, and writes a `data:` SSE frame.
5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies.
6. On client disconnect: deregisters callback, stops heartbeat, removes from `activeClients` map.
The browser's native `EventSource` API handles reconnection automatically on network interruption.
### 5.4 Download Matching Pipeline
For each connected user the server:
1. Reads all `poll:*` keys from `MemoryCache`.
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
3. For each SABnzbd/qBittorrent download item, attempts matches in priority order: Sonarr queue → Radarr queue → Sonarr history → Radarr history.
4. Title matching is a **bidirectional, case-insensitive substring match**: `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
5. For each match, resolves the series/movie, extracts user tags, checks ownership.
6. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
```mermaid
flowchart TD
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
SQ -->|yes| SQR["Resolve series · extract user tag"]
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
RQ -->|yes| RQR["Resolve movie · extract user tag"]
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
SH -->|yes| SHR["Resolve series via seriesId"]
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
RH -->|yes| RHR["Resolve movie via movieId"]
RH -->|no| Skip(["Skip — unmatched"])
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
Tagged -->|yes| Include(["Include in response"])
Tagged -->|no| Skip
```
#### Tag matching
Users are matched to downloads via Sonarr/Radarr tags:
1. **Exact match** — tag label (lowercased) === username (lowercased).
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
#### Matched download object fields
| Field | Type | Description |
|-------|------|-------------|
| `type` | `'series'`/`'movie'`/`'torrent'` | Media type |
| `title` | string | Raw download title |
| `coverArt` | string/null | Poster URL from *arr |
| `status` | string | Download status |
| `progress` | string | Percentage complete |
| `size`/`mb`/`mbmissing` | string/number | Size info |
| `speed` | string | Current download speed |
| `eta` | string | Estimated time remaining |
| `seriesName`/`movieName` | string | Friendly media title |
| `episodes` | `{season, episode, title}[]` | Episodes covered (sorted); empty array if Sonarr has no data |
| `allTags` | string[] | All resolved tag labels on the series/movie |
| `matchedUserTag` | string/null | Tag label matching the requesting user |
| `tagBadges` | `{label, matchedUser}[]`/undefined | (Admin `showAll` only) each tag classified against Emby user list |
| `importIssues` | string[]/null | Import warning/error messages |
| `canBlocklist` | boolean | `true` if the current user may blocklist this download |
| `downloadPath` | string/null | (Admin) Download client path |
| `targetPath` | string/null | (Admin) *arr target path |
| `arrLink` | string/null | (Admin) Link to *arr web UI |
| `arrQueueId` | number/null | (Admin) Sonarr/Radarr queue record id |
| `arrType` | `'sonarr'`/`'radarr'`/null | (Admin) Which *arr service owns this queue entry |
| `arrInstanceUrl` | string/null | (Admin) Base URL of the *arr instance |
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
---
## 6. Caching and Smart Polling
### 6.1 Cache Layer
`server/utils/cache.js` exports a singleton `MemoryCache` backed by a `Map`. Each entry carries an expiration timestamp. The cache is shared by the poller, webhook processor, and all route modules.
```javascript
class MemoryCache {
get(key): any
set(key, value, ttlMs)
invalidate(key)
clear()
getStats(): CacheStats // per-key size, item count, TTL remaining
// Webhook metrics helpers
updateWebhookMetrics(instanceUrl)
getWebhookMetrics(instanceUrl): { eventsReceived, lastWebhookTimestamp, pollsSkipped }
getGlobalWebhookMetrics(): { lastGlobalWebhookTimestamp }
}
```
### 6.2 Cache Keys
| Key | Content | TTL |
|-----|---------|-----|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | `POLL_INTERVAL × 3` |
| `poll:sab-history` | `{ slots }` | `POLL_INTERVAL × 3` |
| `poll:sonarr-queue` | `{ records }` with embedded `series` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` |
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | `POLL_INTERVAL × 3` |
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
| `poll:radarr-queue` | `{ records }` with embedded `movie` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` |
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to **30 s** and data is fetched on-demand when the dashboard finds an empty cache entry.
### 6.3 Background Polling Modes
| Mode | `POLL_INTERVAL` | Behaviour |
|------|----------------|-----------|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms; SSE subscribers notified after each cycle |
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first dashboard request when cache is empty; cached 30 s |
The poller uses a `polling` boolean flag to prevent concurrent cycles: if an interval fires while the previous poll is still running, the new invocation is skipped and logged.
### 6.4 Smart Polling Optimisation
When Sonarr/Radarr are configured to send webhooks to sofarr, the poller automatically reduces unnecessary API calls:
```
pollAllServices() called every POLL_INTERVAL ms:
globalMetrics = cache.getGlobalWebhookMetrics()
fallbackTriggered = lastGlobalWebhookTimestamp > WEBHOOK_FALLBACK_TIMEOUT ago
for each service type (sonarr, radarr):
shouldSkip = !fallbackTriggered
&& all instances have metrics.eventsReceived > 0
&& all instances have metrics.lastWebhookTimestamp within WEBHOOK_FALLBACK_TIMEOUT
if shouldSkip:
extend TTL of existing cached data ← zero *arr API calls
increment metrics.pollsSkipped
log "[Poller] Skipping sonarr polling for N instance(s) with active webhooks"
else:
fetch from *arr APIs → update cache
```
**Effect:** zero *arr API calls per poll cycle when webhooks are active and recent. The poller automatically falls back to full polling after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10 minutes), ensuring the dashboard remains accurate even if webhooks stop arriving.
### 6.5 Active SSE Client Tracking
SSE connections are tracked precisely in `activeClients` (a `Map` keyed by `${username}:${connectedAt}`): registered on connect, removed on disconnect. The admin status panel shows each connected user and their connection duration. The `type: 'sse'` field distinguishes SSE clients from other connection types.
---
## 7. Key Subsystems
### 7.1 Download Clients
See [Section 3.1](#31-pluggable-download-client-architecture-pdca) for full detail. The client hierarchy is:
```
DownloadClient (abstract — server/clients/DownloadClient.js)
├── SABnzbdClient.js — Usenet; REST; API key auth
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
```
`server/utils/qbittorrent.js` is a legacy compatibility shim that delegates to `QBittorrentClient`.
### 7.2 Queue & History Processing
**`server/utils/historyFetcher.js`** fetches history records from all Sonarr/Radarr instances for a configurable date window. Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`.
**`server/routes/history.js`** (`GET /api/history/recent`) returns recently completed (imported or failed) downloads filtered for the authenticated user. Supports `?days=N` (default `RECENT_COMPLETED_DAYS`, capped at 90) and `?showAll=true` for admins. Results are sorted newest first.
**`server/routes/dashboard.js`** (`POST /api/dashboard/blocklist-search`) removes a Sonarr/Radarr queue item with `blocklist=true` and immediately triggers an `EpisodeSearch` or `MoviesSearch` command. Non-admin users may only blocklist when import issues are present, or (for qBittorrent only) the torrent is over 1 hour old with less than 100% availability.
### 7.3 Dashboard & Frontend
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `public/app.js`, styled by `public/style.css`, and structured by `public/index.html`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
#### UI state machine
```mermaid
stateDiagram-v2
[*] --> SplashScreen : Page load
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session
CheckAuth --> Dashboard : Valid session
LoginForm --> Dashboard : Auth success (fade transition)
Dashboard --> LoginForm : Logout (stopSSE)
state Dashboard {
[*] --> Rendering
Rendering --> Rendering : SSE message → renderDownloads()
state SSEConnection {
[*] --> Connecting
Connecting --> Connected : First message
Connected --> Reconnecting : Connection lost
Reconnecting --> Connected : Auto-reconnect
Connected --> Connecting : showAll toggled
}
state StatusPanel {
[*] --> Closed
Closed --> Open : Click Status (admin)
Open --> Closed : Click close
Open --> Open : 5s timer refresh
}
}
```
#### Key frontend functions
| Function | Purpose |
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` |
| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards |
#### Tag badge rendering
- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
---
## 8. Directory Structure
```
sofarr/
├── server/
│ ├── app.js Express app factory — imported by tests and index.js
│ ├── index.js Entry point: logging setup, server listen, poller start
│ ├── clients/ PDCA — one file per download client + retriever
│ │ ├── DownloadClient.js Abstract base class for all download clients
│ │ ├── QBittorrentClient.js
│ │ ├── SABnzbdClient.js
│ │ ├── TransmissionClient.js
│ │ ├── RTorrentClient.js
│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
│ ├── routes/
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
│ │ ├── dashboard.js SSE /stream, /user-downloads, /user-summary, /status, /cover-art, /blocklist-search
│ │ ├── history.js GET /api/history/recent
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
│ │ ├── sonarr.js Sonarr API proxy + webhook management
│ │ ├── radarr.js Radarr API proxy + webhook management
│ │ ├── emby.js Emby API proxy
│ │ └── sabnzbd.js SABnzbd API proxy
│ ├── middleware/
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
│ └── utils/
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
│ ├── cache.js MemoryCache + webhook metrics helpers
│ ├── config.js Multi-instance config parser
│ ├── downloadClients.js PDCA registry + factory
│ ├── historyFetcher.js History fetch + event classification
│ ├── logger.js File logger (DATA_DIR/server.log)
│ ├── poller.js Smart background polling engine
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
│ ├── sanitizeError.js Secret redaction from errors/logs
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
├── public/ Static SPA (served by Express)
│ ├── index.html HTML shell: splash, login, dashboard
│ ├── app.js All frontend logic
│ ├── style.css Themes, layout, responsive design
│ ├── favicon.ico / *.png Favicons
│ └── images/ Logo / splash screen assets
├── tests/
│ ├── README.md Testing approach and coverage targets
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
│ ├── unit/ Pure unit tests (no HTTP)
│ └── integration/ Supertest + nock integration tests
├── .gitea/workflows/
│ ├── ci.yml Security audit + test/coverage on every push/PR
│ ├── build-image.yml Docker image build and push
│ ├── create-release.yml Release tagging workflow
│ ├── docs-check.yml Markdown lint + Mermaid validation
│ └── licence-check.yml Production dependency licence check
├── Dockerfile Multi-stage production image (node:22-alpine)
├── docker-compose.yaml Example compose deployment
├── vitest.config.js Test runner configuration with per-file coverage thresholds
├── package.json Dependencies and scripts
├── ARCHITECTURE.md This document
├── SECURITY.md Threat model and hardening guide
├── CHANGELOG.md Version history
└── .env.sample Annotated environment variable template
```
---
## 9. Configuration and Environment Variables
### 9.1 Core Server
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation |
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable. In Docker: `/app/data` (named volume). |
| `COOKIE_SECRET` | No* | — | Signs all session cookies with HMAC-SHA256. **Strongly recommended in production** (server exits on startup if unset in `NODE_ENV=production`). Generate with `openssl rand -hex 32`. |
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` when behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) so `req.ip` and `req.secure` are correct. |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
| `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window for `/api/history/recent`. Overridable per-request via `?days=`. Capped at 90. |
### 9.2 TLS / HTTPS
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `TLS_ENABLED` | No | `true` | Set to `false` to run plain HTTP (e.g. when TLS is terminated by a reverse proxy). |
| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to TLS certificate (PEM). Defaults to the bundled self-signed snakeoil certificate. |
| `TLS_KEY` | No | `certs/snakeoil.key` | Path to TLS private key (PEM). |
### 9.3 Webhook
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `SOFARR_WEBHOOK_SECRET` | Yes* | — | Shared secret validated on the `X-Sofarr-Webhook-Secret` header. Webhook endpoints reject all requests if this is not set. Generate with `openssl rand -hex 32`. |
| `SOFARR_BASE_URL` | Yes* | — | Public base URL of this sofarr instance (e.g. `https://sofarr.example.com`). Used by the one-click webhook configuration endpoints to tell Sonarr/Radarr where to send events. |
| `WEBHOOK_FALLBACK_TIMEOUT` | No | `10` | Minutes of silence after which the poller falls back to full polling even when webhooks were recently active. |
### 9.4 Polling
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `POLL_INTERVAL` | No | `5000` | Background poll interval in ms. Set to `0`, `off`, or `false` to disable and use on-demand mode. |
### 9.5 Emby
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
| `EMBY_API_KEY` | Yes | — | Emby API key — used by the poller to list users for tag badge classification |
### 9.6 Service Instances
All service instances support both a JSON array format (recommended) and a legacy single-instance format:
| Variable | Required | Format |
|----------|:--------:|--------|
| `SONARR_INSTANCES` | Yes* | JSON array |
| `SONARR_URL` + `SONARR_API_KEY` | Yes* | Legacy single-instance |
| `RADARR_INSTANCES` | Yes* | JSON array |
| `RADARR_URL` + `RADARR_API_KEY` | Yes* | Legacy single-instance |
| `SABNZBD_INSTANCES` | Yes* | JSON array |
| `SABNZBD_URL` + `SABNZBD_API_KEY` | Yes* | Legacy single-instance |
| `QBITTORRENT_INSTANCES` | No | JSON array (uses `username`/`password` not `apiKey`) |
| `RTORRENT_INSTANCES` | No | JSON array (URL must include the full XML-RPC path, e.g. `/RPC2`) |
\* Either `*_INSTANCES` or the legacy pair is required for each service.
#### JSON array instance format
```json
[
{ "name": "main", "url": "https://sonarr.example.com", "apiKey": "your-api-key" },
{ "name": "4k", "url": "https://sonarr4k.example.com", "apiKey": "your-4k-api-key" }
]
```
qBittorrent and rTorrent instances use `username` and `password` instead of `apiKey`.
Each instance receives an `id` derived from `name` (or index if unnamed), used as the key in PDCA and PALDRA registries.
---
## 10. Security Model
### 10.1 Authentication and Sessions
| Concern | Mechanism |
|---------|-----------|
| **User authentication** | Emby credentials via `POST /Users/authenticatebyname`. A deterministic `DeviceId` (SHA-256 of username, first 16 chars) ensures Emby reuses the same session on every login. |
| **Session cookie** | `httpOnly`, `sameSite: strict`, `secure` when `TRUST_PROXY` is set. Payload: `{ id, name, isAdmin }` only — the Emby `AccessToken` is **never** sent to the browser. Signed with HMAC when `COOKIE_SECRET` is set. |
| **Token store** | Emby `AccessToken`s stored server-side in `DATA_DIR/tokens.json` (atomic writes, 31-day TTL, hourly pruning). Used only for server-side Emby logout. |
| **Session validation** | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, and proxy routes. Returns `401` if the cookie is absent, tampered, or schema-invalid. |
| **CSRF protection** | Double-submit cookie pattern. `verifyCsrf` middleware compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Applied to all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) under `/api/*` except auth and webhook routes. |
| **Remember-me** | `rememberMe: true` → persistent cookie, `Max-Age` 30 days. `rememberMe: false` → session cookie (expires on browser close). |
### 10.2 Webhook Security
| Concern | Mechanism |
|---------|-----------|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
### 10.3 Additional Security Measures
| Concern | Mechanism |
|---------|-----------|
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
| **Body size** | `express.json` body limit: 64 KB. |
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
---
## 11. Technology Stack
### Runtime and Framework
| Layer | Technology | Notes |
|-------|-----------|-------|
| Runtime | Node.js 22 (Alpine) | LTS; ESM-ready; V8 coverage built-in |
| Framework | Express 4.x | HTTP server, routing, middleware |
| HTTP client | axios 1.x | External API communication |
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
| Frontend | Vanilla JS + CSS | SPA, no build step required |
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
### Security Middleware
| Package | Version | Purpose |
|---------|---------|---------|
| `helmet` | 7.x | HTTP security headers (CSP nonce, HSTS, referrer policy, frame options) |
| `express-rate-limit` | 7.x | General, login, and webhook rate limiters |
| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
### Auth and Session
| Component | Technology | Details |
|-----------|-----------|---------|
| Identity provider | Emby / Jellyfin API | `POST /Users/authenticatebyname` |
| Session cookie | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` is set |
| CSRF protection | Double-submit cookie | `csrf_token` cookie + `X-CSRF-Token` header; `crypto.timingSafeEqual` |
| Token store | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
### Testing
| Tool | Version | Purpose |
|------|---------|---------|
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
### CI/CD
| Workflow file | Trigger | Purpose |
|---------------|---------|---------|
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite with V8 coverage |
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
| `licence-check.yml` | Push / PR touching `package.json` | Verify production dependency licences are MIT-compatible |
+73
View File
@@ -6,6 +6,79 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
--- ---
## [1.5.1] - 2026-05-19
### Fixed
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
---
## [1.5.0a] - 2026-05-19
### Fixed
- **Status panel close button** — the `×` button now correctly hides the status panel and stops the auto-refresh timer. The button was previously using an inline `onclick` attribute which was silently blocked by the server's CSP nonce policy. Replaced with `addEventListener` wired after `innerHTML` is set, consistent with all other button handlers in the application.
---
## [1.5.0] - 2026-05-19
### Changed
- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline.
- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`.
---
## [1.4.0] - 2026-05-19
### Added
#### Webhook Integration (Phases 15.1)
- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure.
- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle.
- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser.
- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement.
- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event.
- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active.
- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`.
- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events.
#### Smart Polling Optimization (Phase 5)
- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`.
- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead.
- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`.
- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped.
- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`).
- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`).
- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance.
#### Security Hardening (Phase 6)
- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`.
- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message.
- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast.
- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions.
#### Documentation (Phase 6)
- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count.
- **`CHANGELOG.md`** — this entry.
- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints.
- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow.
- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section.
### Changed
- `poller.js``pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data.
- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton.
- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware.
---
## [1.3.0] - 2026-05-17 ## [1.3.0] - 2026-05-17
### Added ### Added
+118 -17
View File
@@ -4,40 +4,76 @@
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing! **sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
Version 1.4.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
## What It Does ## What It Does
sofarr connects to your media stack and shows you a personalized view of: sofarr connects to your media stack and shows you a personalized view of:
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent) - **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent, Transmission, rTorrent)
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates - **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness - **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr - **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
- **Multi-Instance Support** - Connect to multiple instances of each service - **Multi-Instance Support** - Connect to multiple instances of each service
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
## How It Works ## How It Works
### Architecture Overview ### Architecture Overview
``` ```
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐ ┌─────────────┐ ┌──────────────────────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │ Browser │────▶│ sofarr Server
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │ (User) │◀────│ Auth · Dashboard · History · Webhooks
└─────────────┘ └──────────────┘ Sonarr (TV management) └─────────────┘
│ │ Radarr (Movie management) SSE push ◀───────│ Poller (smart: skips when webhooks active)
│ │ Emby (User authentication) │ Cache · PDCA Download Registry · PALDRA
▼ └─────────────────────────────┘ └───┬─────────────────────────┬────────────────┘
┌──────────────┐ │ polls (background) │ receives webhooks
Dashboard
│ Aggregator │ ┌──────────────────────────┐ ┌─────────▼───────────────────┐
└──────────────┘ │ Download Clients │ │ *arr Services │
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
│ Transmission (Torrent) │ └─────────────────────────────┘
│ rTorrent (Torrent) │
└──────────────────────────┘
Emby / Jellyfin
(User authentication)
``` ```
**Three pluggable layers power sofarr:**
| Layer | Name | What it does |
|-------|------|--------------|
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
### Webhooks
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
**Quick setup:**
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
2. Open the sofarr dashboard → **Webhooks Configuration** panel
3. Click **Enable** next to each Sonarr/Radarr instance
4. sofarr auto-configures the notification connection inside each *arr service
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
- `POST /api/webhook/sonarr` — receives Sonarr events
- `POST /api/webhook/radarr` — receives Radarr events
### The Matching Process ### The Matching Process
1. **User Authentication**: Login via Emby credentials 1. **User Authentication**: Login via Emby credentials
2. **Tag-Based Matching**: 2. **Tag-Based Matching**:
- Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon") - Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon")
- sofarr checks Sonarr/Radarr activity to find items tagged with your name - sofarr checks Sonarr/Radarr activity to find items tagged with your name
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity - Downloads (from SABnzbd, qBittorrent, Transmission, or rTorrent) are matched by title to that activity
- Only your downloads appear on your dashboard - Only your downloads appear on your dashboard
### Multi-Instance Support ### Multi-Instance Support
@@ -53,7 +89,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites ## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation - **Docker** (recommended), or Node.js (v22+) for manual installation
- At least one of: SABnzbd or qBittorrent - At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
- Sonarr (optional, for TV tracking) - Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking) - Radarr (optional, for movie tracking)
- Emby (for user authentication) - Emby (for user authentication)
@@ -108,6 +144,8 @@ docker run -d \
-e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \ -e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \ -e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \ -e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e TRANSMISSION_INSTANCES='[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]' \
-e RTORRENT_INSTANCES='[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]' \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \ -e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest docker.i3omb.com/sofarr:latest
@@ -131,6 +169,8 @@ services:
- RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}] - RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}] - SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]
- RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
- LOG_LEVEL=info - LOG_LEVEL=info
- POLL_INTERVAL=5000 - POLL_INTERVAL=5000
``` ```
@@ -188,6 +228,30 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
# Set to 0 or "off" to disable (on-demand mode) # Set to 0 or "off" to disable (on-demand mode)
``` ```
### Webhooks & Smart Polling
```bash
# Required for webhook endpoints to accept events
SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32)
SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup
# Optional tuning
WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10)
WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3)
```
### Download Clients (PDCA)
sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management.
**Supported Download Clients:**
| Client | Protocol | Auth Method | Notes |
|--------|----------|-------------|-------|
| SABnzbd | REST API | API Key | Usenet downloads |
| qBittorrent | Sync API | Username/Password | BitTorrent with incremental updates |
| Transmission | JSON-RPC | Username/Password | BitTorrent with session management |
| rTorrent | XML-RPC | HTTP Basic Auth | BitTorrent, requires the full RPC endpoint in the url field (e.g. /RPC2 or /xmlrpc for whatbox.ca). No path is automatically appended. |
### Service Instances (JSON Array Format) ### Service Instances (JSON Array Format)
All services support multi-instance configuration via single-line JSON arrays: All services support multi-instance configuration via single-line JSON arrays:
@@ -199,10 +263,21 @@ SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey
# qBittorrent Instances (uses username/password, not API key) # qBittorrent Instances (uses username/password, not API key)
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}] QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
# Transmission Instances (uses username/password)
TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091/transmission/rpc","username":"admin","password":"pass"}]
# rTorrent Instances (uses username/password, URL must include full RPC endpoint)
# Standard installs use /RPC2. Some providers like whatbox.ca use /xmlrpc.
# No path is automatically appended - always include the full RPC endpoint.
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca (example):
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# Sonarr Instances # Sonarr Instances
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}] SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
# Radarr Instances # Radarr Instances
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}] RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
# Emby (single instance for authentication) # Emby (single instance for authentication)
@@ -216,6 +291,18 @@ If you only have one instance, you can use the legacy format:
```bash ```bash
SABNZBD_URL=https://sabnzbd.example.com SABNZBD_URL=https://sabnzbd.example.com
SABNZBD_API_KEY=your-api-key SABNZBD_API_KEY=your-api-key
QBITTORRENT_URL=https://qbittorrent.example.com
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=secret
TRANSMISSION_URL=http://transmission:9091/transmission/rpc
TRANSMISSION_USERNAME=admin
TRANSMISSION_PASSWORD=pass
RTORRENT_URL=http://rtorrent:8080/RPC2
RTORRENT_USERNAME=rtorrent
RTORRENT_PASSWORD=rtorrent
``` ```
## Setting Up User Tags ## Setting Up User Tags
@@ -285,6 +372,20 @@ sofarr polls all configured services in the background and caches the results. D
### History ### History
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history - `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
- `POST /api/webhook/radarr` — receive Radarr webhook events
### Webhook Management (requires auth + CSRF)
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection
- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
- `POST /api/radarr/webhook/test` — trigger a Radarr test event
### Service APIs (proxy to your services) ### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` — SABnzbd API proxy - `GET /api/sabnzbd/*` — SABnzbd API proxy
- `GET /api/sonarr/*` — Sonarr API proxy - `GET /api/sonarr/*` — Sonarr API proxy
@@ -328,7 +429,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
npm run test:ui # interactive Vitest UI npm run test:ui # interactive Vitest UI
``` ```
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. 290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development ## Development
+17 -1
View File
@@ -4,8 +4,10 @@
| Version | Supported | | Version | Supported |
|---------|-----------| |---------|-----------|
| 1.4.x | ✅ Yes |
| 1.3.x | ✅ Yes |
| 1.2.x | ✅ Yes | | 1.2.x | ✅ Yes |
| 1.1.x | ✅ Yes | | 1.1.x | ❌ No |
| 1.0.x | ❌ No | | 1.0.x | ❌ No |
| < 1.0 | ❌ No | | < 1.0 | ❌ No |
@@ -35,6 +37,10 @@ users via Emby. The primary threat surface when exposed to the public internet:
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped | | Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept | | Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push | | Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
--- ---
@@ -49,6 +55,15 @@ users via Emby. The primary threat surface when exposed to the public internet:
- [ ] HTTPS enforced by the reverse proxy with a valid certificate - [ ] HTTPS enforced by the reverse proxy with a valid certificate
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed - [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
### Webhook-Specific (if using webhook integration)
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
### Recommended ### Recommended
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination - [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
@@ -145,6 +160,7 @@ server {
|----------|-------| |----------|-------|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP | | `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
| All `/api/*` routes | 300 requests per 15 min per IP | | All `/api/*` routes | 300 requests per 15 min per IP |
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
--- ---
+155
View File
@@ -304,3 +304,158 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Webhooks Section Styles */
.webhooks-section {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow: hidden;
}
.webhooks-header {
padding: 20px 30px;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s;
}
.webhooks-header:hover {
background: #f0f1f2;
}
.webhooks-header h2 {
color: #333;
font-size: 1.3rem;
margin: 0;
}
.webhooks-toggle {
font-size: 1.2rem;
color: #666;
transition: transform 0.3s;
}
.webhooks-toggle.expanded {
transform: rotate(180deg);
}
.webhooks-content {
padding: 20px 30px;
}
.webhook-instance {
padding: 20px 0;
border-bottom: 1px solid #e0e0e0;
}
.webhook-instance:last-child {
border-bottom: none;
}
.webhook-instance h3 {
color: #333;
font-size: 1.1rem;
margin-bottom: 15px;
}
.webhook-status {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.status-indicator {
font-size: 1rem;
font-weight: 500;
padding: 5px 15px;
border-radius: 20px;
}
.status-indicator.enabled {
background: #e8f5e9;
color: #4caf50;
}
.status-indicator.disabled {
background: #f5f5f5;
color: #999;
}
.enable-webhook-btn {
padding: 8px 16px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s;
}
.enable-webhook-btn:hover {
background: #5568d3;
}
.enable-webhook-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.test-webhook-btn {
padding: 8px 16px;
background: #f093fb;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s;
}
.test-webhook-btn:hover {
background: #d97ed8;
}
.test-webhook-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.webhook-triggers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.trigger-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.trigger-label {
color: #666;
font-size: 0.9rem;
}
.trigger-value {
font-weight: 500;
font-size: 1.1rem;
}
.trigger-value.active {
color: #4caf50;
}
.trigger-value.inactive {
color: #999;
}
+216
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import './App.css'; import './App.css';
@@ -9,9 +10,14 @@ function App() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
const [webhookLoading, setWebhookLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchSessions(); fetchSessions();
fetchWebhookStatus();
}, []); }, []);
const fetchSessions = async () => { const fetchSessions = async () => {
@@ -66,6 +72,112 @@ function App() {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const fetchWebhookStatus = async () => {
try {
// Fetch Sonarr notifications
try {
const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
setSonarrWebhook({
enabled: !!sonarrSofarr,
triggers: sonarrSofarr ? {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
});
} catch (err) {
// Sonarr not configured or not accessible
setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
}
// Fetch Radarr notifications
try {
const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
setRadarrWebhook({
enabled: !!radarrSofarr,
triggers: radarrSofarr ? {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
});
} catch (err) {
// Radarr not configured or not accessible
setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
}
} catch (err) {
console.error('Failed to fetch webhook status:', err);
}
};
const enableSonarrWebhook = async () => {
setWebhookLoading(true);
try {
await axios.post('/api/sonarr/notifications/sofarr-webhook');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
alert('Failed to enable Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const enableRadarrWebhook = async () => {
setWebhookLoading(true);
try {
await axios.post('/api/radarr/notifications/sofarr-webhook');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
alert('Failed to enable Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const testSonarrWebhook = async () => {
setWebhookLoading(true);
try {
const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
if (sonarrSofarr) {
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
alert('Sonarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Sonarr.');
}
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
alert('Failed to test Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
const testRadarrWebhook = async () => {
setWebhookLoading(true);
try {
const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
if (radarrSofarr) {
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
alert('Radarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Radarr.');
}
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
alert('Failed to test Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
};
return ( return (
<div className="app"> <div className="app">
<header className="app-header"> <header className="app-header">
@@ -177,6 +289,110 @@ function App() {
</div> </div>
)} )}
<div className="webhooks-section">
<div className="webhooks-header" onClick={() => setWebhookSectionExpanded(!webhookSectionExpanded)}>
<h2> Webhooks Configuration</h2>
<span className={`webhooks-toggle ${webhookSectionExpanded ? 'expanded' : ''}`}></span>
</div>
{webhookSectionExpanded && (
<div className="webhooks-content">
{webhookLoading && <div className="loading">Loading webhook status...</div>}
<div className="webhook-instance">
<h3>Sonarr</h3>
<div className="webhook-status">
<span className={`status-indicator ${sonarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
{sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
</span>
{!sonarrWebhook.enabled && (
<button onClick={enableSonarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
Enable Sofarr Webhooks
</button>
)}
{sonarrWebhook.enabled && (
<button onClick={testSonarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
Test
</button>
)}
</div>
{sonarrWebhook.enabled && (
<div className="webhook-triggers">
<div className="trigger-item">
<span className="trigger-label">On Grab</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onGrab ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Download</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onDownload ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Import</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onImport ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Upgrade</span>
<span className={`trigger-value ${sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
{sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
</span>
</div>
</div>
)}
</div>
<div className="webhook-instance">
<h3>Radarr</h3>
<div className="webhook-status">
<span className={`status-indicator ${radarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
{radarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
</span>
{!radarrWebhook.enabled && (
<button onClick={enableRadarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
Enable Sofarr Webhooks
</button>
)}
{radarrWebhook.enabled && (
<button onClick={testRadarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
Test
</button>
)}
</div>
{radarrWebhook.enabled && (
<div className="webhook-triggers">
<div className="trigger-item">
<span className="trigger-label">On Grab</span>
<span className={`trigger-value ${radarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onGrab ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Download</span>
<span className={`trigger-value ${radarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onDownload ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Import</span>
<span className={`trigger-value ${radarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onImport ? '✓' : '✗'}
</span>
</div>
<div className="trigger-item">
<span className="trigger-label">On Upgrade</span>
<span className={`trigger-value ${radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
{radarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
<footer className="app-footer"> <footer className="app-footer">
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p> <p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
</footer> </footer>
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
+399
View File
@@ -0,0 +1,399 @@
# Adding a New Download Client to Sofarr
This guide explains how to add support for a new download client to Sofarr using the Pluggable Download Client Architecture (PDCA).
## Overview
The PDCA makes adding new download clients straightforward by providing a standardized interface. You only need to implement the `DownloadClient` abstract base class and register your client in the configuration system.
## Prerequisites
- Familiarity with JavaScript/Node.js
- Understanding of your target client's API
- Basic knowledge of Sofarr's architecture (see [ARCHITECTURE.md](ARCHITECTURE.md))
## Step 1: Create the Client Class
Create a new file in `server/clients/` named after your client (e.g., `DelugeClient.js`).
```javascript
// server/clients/DelugeClient.js
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class DelugeClient extends DownloadClient {
constructor(instance) {
super(instance);
// Add any client-specific initialization here
this.sessionId = null;
this.rpcUrl = `${this.url}/json`;
}
getClientType() {
return 'deluge';
}
async testConnection() {
try {
// Implement connection test logic
const response = await this.makeRequest('auth.check_session');
logToFile(`[Deluge:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[Deluge:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(method, params = []) {
// Implement RPC call logic
const payload = {
method: method,
params: params,
id: Date.now()
};
// Add authentication if needed
if (this.sessionId) {
payload.params.unshift(this.sessionId);
}
// Make HTTP request to your client's API
// Handle authentication, errors, etc.
}
async getActiveDownloads() {
try {
// Fetch downloads from your client
const torrents = await this.makeRequest('core.get_torrents_status',
[{}, ['name', 'state', 'progress', 'total_size', 'download_payload_rate']]
);
// Normalize each download using the standard schema
return Object.entries(torrents).map(([id, torrent]) =>
this.normalizeDownload({ ...torrent, id })
);
} catch (error) {
logToFile(`[Deluge:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
// Optional: Return client status information
const status = await this.makeRequest('core.get_session_status');
return status;
} catch (error) {
logToFile(`[Deluge:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
// Convert client-specific data to the normalized schema
return {
id: torrent.id,
title: torrent.name,
type: 'torrent',
client: 'deluge',
instanceId: this.id,
instanceName: this.name,
status: this.mapStatus(torrent.state),
progress: Math.round(torrent.progress * 100),
size: torrent.total_size,
downloaded: Math.round(torrent.total_size * torrent.progress),
speed: torrent.download_payload_rate,
eta: torrent.eta > 0 ? torrent.eta : null,
category: torrent.label || undefined,
tags: torrent.tracker ? [torrent.tracker] : [],
savePath: torrent.save_path,
addedOn: torrent.added_time ? new Date(torrent.added_time * 1000).toISOString() : undefined,
raw: torrent // Include original data for advanced use cases
};
}
mapStatus(state) {
// Map client-specific states to normalized statuses
const statusMap = {
'Downloading': 'Downloading',
'Seeding': 'Seeding',
'Paused': 'Paused',
'Checking': 'Checking',
'Error': 'Error',
'Queued': 'Queued'
};
return statusMap[state] || state;
}
}
module.exports = DelugeClient;
```
## Step 2: Add Configuration Support
Update `server/utils/config.js` to add support for your client's environment variables:
```javascript
function getDelugeInstances() {
return parseInstances(
process.env.DELUGE_INSTANCES,
process.env.DELUGE_URL,
null, // no apiKey for Deluge
process.env.DELUGE_USERNAME,
process.env.DELUGE_PASSWORD
);
}
// Add to module.exports
module.exports = {
// ... existing exports
getDelugeInstances,
// ... other exports
};
```
## Step 3: Register the Client
Update `server/utils/downloadClients.js` to include your client:
```javascript
const DelugeClient = require('../clients/DelugeClient');
// Add to clientClasses mapping
const clientClasses = {
sabnzbd: SABnzbdClient,
qbittorrent: QBittorrentClient,
transmission: TransmissionClient,
deluge: DelugeClient // Add your client here
};
// Update instance configuration
const instanceConfigs = [
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
...delugeInstances.map(inst => ({ ...inst, type: 'deluge' })) // Add this line
];
```
## Step 4: Update Poller Integration
The poller automatically uses the registry, so no changes are needed there. However, if you want to maintain backward compatibility with existing cache keys, you may need to update the poller's transformation logic.
## Step 5: Add Tests
Create comprehensive tests for your client:
```javascript
// tests/unit/clients/DelugeClient.test.js
const DelugeClient = require('../../../server/clients/DelugeClient');
describe('DelugeClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-deluge',
name: 'Test Deluge',
url: 'http://localhost:8112',
username: 'admin',
password: 'deluge'
};
client = new DelugeClient(mockConfig);
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('deluge');
expect(client.getInstanceId()).toBe('test-deluge');
expect(client.name).toBe('Test Deluge');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
// Mock successful connection
client.makeRequest = jest.fn().mockResolvedValue({ result: true });
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('auth.check_session');
});
});
describe('Download Normalization', () => {
it('should normalize download data correctly', () => {
const torrent = {
id: 'abc123',
name: 'Test Torrent',
state: 'Downloading',
progress: 0.75,
total_size: 1000000000,
download_payload_rate: 1048576,
eta: 3600,
label: 'movies',
save_path: '/downloads/test'
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'deluge',
instanceId: 'test-deluge',
instanceName: 'Test Deluge',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: [],
savePath: '/downloads/test',
raw: torrent
});
});
});
// Add more tests for error handling, edge cases, etc.
});
```
## Step 6: Configuration Examples
Add documentation for your client's configuration in `.env.sample`:
```bash
# Deluge Configuration
# Single instance (legacy format)
# DELUGE_URL=http://localhost:8112
# DELUGE_USERNAME=admin
# DELUGE_PASSWORD=deluge
# Multiple instances (JSON format)
DELUGE_INSTANCES='[
{
"name": "Main Deluge",
"url": "http://localhost:8112",
"username": "admin",
"password": "deluge"
},
{
"name": "Backup Deluge",
"url": "http://localhost:8113",
"username": "admin",
"password": "deluge"
}
]'
```
## Step 7: Update Documentation
Update relevant documentation files:
1. **ARCHITECTURE.md**: Add your client to the download clients section
2. **README.md**: Add configuration instructions for your client
3. **CHANGELOG.md**: Document the new client support
## Best Practices
### Error Handling
- Always wrap API calls in try-catch blocks
- Return empty arrays for download fetch failures
- Log errors with appropriate context
- Implement retry logic where appropriate
### Authentication
- Store credentials securely (don't log them)
- Handle session expiration gracefully
- Implement automatic re-authentication when possible
### Performance
- Use efficient API calls (batch requests when available)
- Implement caching for expensive operations
- Consider pagination for large download lists
- Use connection pooling for HTTP clients
### Normalization
- Always return the complete normalized schema
- Handle missing or null values gracefully
- Preserve original data in the `raw` field
- Map client-specific statuses to standard ones
### Testing
- Test both success and failure scenarios
- Mock external API calls
- Test normalization edge cases
- Include integration tests
## Example: Complete Implementation
For a complete example, refer to the existing client implementations:
- **SABnzbdClient.js**: Simple REST API client
- **QBittorrentClient.js**: Complex client with sync API and fallback
- **TransmissionClient.js**: JSON-RPC client with session management
- **RTorrentClient.js**: XML-RPC client with HTTP Basic Auth
### rTorrent Specific Notes
rTorrent uses XML-RPC over HTTP with the following specifics:
- **Endpoint**: `${url}/RPC2` (most common)
- **Authentication**: HTTP Basic Auth (handled by reverse proxy or web server)
- **Primary Method**: `d.multicall2` for efficient bulk torrent data retrieval
- **Library**: Uses the `xmlrpc` package (v1.3.2)
- **Status Mapping**: Combines `d.state`, `d.is_active`, and `d.is_hash_checking` to determine status
- **ETA Calculation**: Computed from download speed and remaining bytes when actively downloading
## Troubleshooting
### Common Issues
1. **Authentication failures**: Check credentials and URL format
2. **API changes**: Ensure your client matches the API version
3. **Network issues**: Implement proper timeout and retry logic
4. **Data normalization**: Verify all required fields are populated
### Debugging
- Enable debug logging in your client
- Check the server logs for error messages
- Use the test connection endpoint to verify configuration
- Test API calls manually before implementing
## Contributing
When contributing a new client:
1. Follow the existing code style and patterns
2. Include comprehensive tests
3. Update all relevant documentation
4. Test with multiple instances if supported
5. Consider edge cases and error scenarios
## Support
If you need help implementing a new client:
1. Review existing client implementations
2. Check the architecture documentation
3. Look at the test examples
4. Ask questions in the project discussions
---
*This guide covers the basics of adding a new download client. For more advanced scenarios, refer to the source code and existing implementations.*
-1535
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

+33 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
@@ -15,7 +15,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.0.0", "express-rate-limit": "^7.0.0",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"jsdom": "^29.1.1" "jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
@@ -3127,6 +3128,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"license": "ISC"
},
"node_modules/saxes": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -3998,12 +4005,35 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/xmlbuilder": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz",
"integrity": "sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlchars": { "node_modules/xmlchars": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/xmlrpc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/xmlrpc/-/xmlrpc-1.3.2.tgz",
"integrity": "sha512-jQf5gbrP6wvzN71fgkcPPkF4bF/Wyovd7Xdff8d6/ihxYmgETQYSuTc+Hl+tsh/jmgPLro/Aro48LMFlIyEKKQ==",
"license": "MIT",
"dependencies": {
"sax": "1.2.x",
"xmlbuilder": "8.2.x"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.0.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.3.1", "version": "1.5.1",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
@@ -22,7 +22,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.0.0", "express-rate-limit": "^7.0.0",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"jsdom": "^29.1.1" "jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",
+5 -1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
let currentUser = null; let currentUser = null;
let downloads = []; let downloads = [];
let isAdmin = false; let isAdmin = false;
@@ -811,7 +812,7 @@ function renderStatusPanel(data, panel) {
let html = ` let html = `
<div class="status-header"> <div class="status-header">
<h3>Server Status</h3> <h3>Server Status</h3>
<button class="status-close" onclick="closeStatusPanel()">&times;</button> <button class="status-close" id="status-close-btn">&times;</button>
</div> </div>
<div class="status-grid"> <div class="status-grid">
<div class="status-card"> <div class="status-card">
@@ -885,6 +886,9 @@ function renderStatusPanel(data, panel) {
html += `</tbody></table></div></div>`; html += `</tbody></table></div></div>`;
panel.innerHTML = html; panel.innerHTML = html;
// Wire close button — addEventListener avoids CSP inline handler restrictions
const closeBtn = panel.querySelector('#status-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions // Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => { panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
el.style.width = el.dataset.w + '%'; el.style.width = el.dataset.w + '%';
+3
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Express application factory — imported by both server/index.js (production) * Express application factory — imported by both server/index.js (production)
* and the test suite. Keeping app creation separate from app.listen() means * and the test suite. Keeping app creation separate from app.listen() means
@@ -18,6 +19,7 @@ const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard'); const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history'); const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf'); const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) { function createApp({ skipRateLimits = false } = {}) {
@@ -93,6 +95,7 @@ function createApp({ skipRateLimits = false } = {}) {
// API routes // API routes
app.use('/api', apiLimiter); app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// CSRF protection for all state-changing API requests below // CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf); app.use('/api', verifyCsrf);
+78
View File
@@ -0,0 +1,78 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Abstract base class for all *arr data retrievers.
* Defines the common interface that all retrievers must implement.
* This pluggable layer enables future retrieval strategies (e.g., webhook listeners)
* to push normalized data directly into the existing cache and SSE system
* without touching the poller logic.
*/
class ArrRetriever {
/**
* @param {Object} instanceConfig - Configuration for this retriever instance
* @param {string} instanceConfig.id - Unique identifier for this instance
* @param {string} instanceConfig.name - Display name for this instance
* @param {string} instanceConfig.url - Base URL for the *arr API
* @param {string} instanceConfig.apiKey - API key for authentication
*/
constructor(instanceConfig) {
if (this.constructor === ArrRetriever) {
throw new Error('ArrRetriever is an abstract class and cannot be instantiated directly');
}
this.id = instanceConfig.id;
this.name = instanceConfig.name;
this.url = instanceConfig.url;
this.apiKey = instanceConfig.apiKey;
}
/**
* Get the retriever type identifier (e.g., 'sonarr', 'radarr')
* @returns {string} The retriever type
*/
getRetrieverType() {
throw new Error('getRetrieverType() must be implemented by subclass');
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Get tags from this *arr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
throw new Error('getTags() must be implemented by subclass');
}
/**
* Get queue from this *arr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
throw new Error('getQueue() must be implemented by subclass');
}
/**
* Get history from this *arr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries] - Include series data (Sonarr)
* @param {boolean} [options.includeEpisode] - Include episode data (Sonarr)
* @param {boolean} [options.includeMovie] - Include movie data (Radarr)
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
throw new Error('getHistory() must be implemented by subclass');
}
}
module.exports = ArrRetriever;
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Abstract base class for all download clients.
* Defines the common interface that all download clients must implement.
*/
class DownloadClient {
/**
* @param {Object} instanceConfig - Configuration for this client instance
* @param {string} instanceConfig.id - Unique identifier for this instance
* @param {string} instanceConfig.name - Display name for this instance
* @param {string} instanceConfig.url - Base URL for the client API
* @param {string} [instanceConfig.apiKey] - API key for authentication (if applicable)
* @param {string} [instanceConfig.username] - Username for authentication (if applicable)
* @param {string} [instanceConfig.password] - Password for authentication (if applicable)
*/
constructor(instanceConfig) {
if (this.constructor === DownloadClient) {
throw new Error('DownloadClient is an abstract class and cannot be instantiated directly');
}
this.id = instanceConfig.id;
this.name = instanceConfig.name;
this.url = instanceConfig.url;
this.apiKey = instanceConfig.apiKey;
this.username = instanceConfig.username;
this.password = instanceConfig.password;
}
/**
* Get the client type identifier (e.g., 'qbittorrent', 'sabnzbd', 'transmission')
* @returns {string} The client type
*/
getClientType() {
throw new Error('getClientType() must be implemented by subclass');
}
/**
* Get the unique instance ID
* @returns {string} The instance ID
*/
getInstanceId() {
return this.id;
}
/**
* Test connection to the download client
* @returns {Promise<boolean>} True if connection is successful
*/
async testConnection() {
throw new Error('testConnection() must be implemented by subclass');
}
/**
* Get active downloads from this client
* @returns {Promise<Array<NormalizedDownload>>} Array of normalized download objects
*/
async getActiveDownloads() {
throw new Error('getActiveDownloads() must be implemented by subclass');
}
/**
* Optional: Get client status information
* @returns {Promise<Object|null>} Client status object or null if not supported
*/
async getClientStatus() {
return null; // Default implementation - optional method
}
/**
* Normalize a download object to the standard schema
* @param {Object} download - Raw download object from client
* @returns {NormalizedDownload} Normalized download object
*/
normalizeDownload(download) {
throw new Error('normalizeDownload() must be implemented by subclass');
}
}
/**
* @typedef {Object} NormalizedDownload
* @property {string} id - Client-specific unique ID
* @property {string} title - Download title/name
* @property {'usenet'|'torrent'} type - Download type
* @property {string} client - Client identifier ('sabnzbd', 'qbittorrent', 'transmission', etc.)
* @property {string} instanceId - Instance identifier
* @property {string} instanceName - Instance display name
* @property {string} status - Normalized status (Downloading, Seeding, Paused, etc.)
* @property {number} progress - Progress percentage (0-100)
* @property {number} size - Total size in bytes
* @property {number} downloaded - Downloaded bytes
* @property {number} speed - Current speed in bytes/sec
* @property {number|null} eta - Estimated time remaining in seconds, null if unknown
* @property {string|undefined} category - Download category (optional)
* @property {string[]|undefined} tags - Download tags (optional)
* @property {string|undefined} savePath - Save path (optional)
* @property {string|undefined} addedOn - Added timestamp (optional)
* @property {number|undefined} arrQueueId - Sonarr/Radarr queue ID (optional)
* @property {'series'|'movie'|undefined} arrType - Sonarr/Radarr type (optional)
* @property {any|undefined} raw - Original client response (escape hatch)
*/
module.exports = DownloadClient;
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Radarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingRadarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'radarr';
}
/**
* Get tags from Radarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Radarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeMovie: true }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} queue error: ${error.message}`);
return { records: [] };
}
}
/**
* Get history from Radarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=10] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeMovie=true] - Include movie data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 10,
sortKey,
sortDir,
includeMovie = true,
startDate
} = options;
try {
const params = {
pageSize,
includeMovie
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} history error: ${error.message}`);
return { records: [] };
}
}
}
module.exports = PollingRadarrRetriever;
+96
View File
@@ -0,0 +1,96 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Sonarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingSonarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'sonarr';
}
/**
* Get tags from Sonarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Sonarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeSeries: true, includeEpisode: true }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} queue error: ${error.message}`);
return { records: [] };
}
}
/**
* Get history from Sonarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=10] - Number of records to fetch
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries=true] - Include series data
* @param {boolean} [options.includeEpisode=true] - Include episode data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 10,
sortKey,
sortDir,
includeSeries = true,
includeEpisode = true,
startDate
} = options;
try {
const params = {
pageSize,
includeSeries,
includeEpisode
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} history error: ${error.message}`);
return { records: [] };
}
}
}
module.exports = PollingSonarrRetriever;
+256
View File
@@ -0,0 +1,256 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class QBittorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this.authCookie = null;
// Sync API incremental state
this.lastRid = 0;
this.torrentMap = new Map();
this.fallbackThisCycle = false;
}
getClientType() {
return 'qbittorrent';
}
async testConnection() {
try {
await this.login();
// Try a simple API call to verify connection
await this.makeRequest('/api/v2/app/version');
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
/**
* Fetches incremental torrent data using the qBittorrent Sync API.
*/
async getMainData() {
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
const data = response.data;
if (data.full_update) {
// Full refresh: rebuild the entire map
this.torrentMap.clear();
if (data.torrents) {
for (const [hash, props] of Object.entries(data.torrents)) {
this.torrentMap.set(hash, { ...props, hash });
}
}
} else {
// Delta update: merge changed fields into existing torrent objects
if (data.torrents) {
for (const [hash, delta] of Object.entries(data.torrents)) {
const existing = this.torrentMap.get(hash) || { hash };
this.torrentMap.set(hash, { ...existing, ...delta });
}
}
}
// Remove torrents that the server reports as deleted
if (data.torrents_removed) {
for (const hash of data.torrents_removed) {
this.torrentMap.delete(hash);
}
}
// Ensure every torrent has a computed 'completed' field for downstream consumers
for (const torrent of this.torrentMap.values()) {
if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) {
torrent.completed = Math.round(torrent.size * torrent.progress);
}
}
this.lastRid = data.rid;
return Array.from(this.torrentMap.values());
}
/**
* Legacy full-list fetch. Used as a fallback when the Sync API fails.
*/
async getTorrentsLegacy() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`);
return response.data;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
if (this.fallbackThisCycle) {
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
}
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
this.fallbackThisCycle = true;
try {
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
return [];
}
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('/api/v2/sync/maindata');
const data = response.data;
return {
serverState: data.server_state || {},
rid: data.rid,
fullUpdate: data.full_update
};
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const totalSize = torrent.size;
const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress);
const progress = torrent.progress * 100;
// Map qBittorrent states to our normalized status
const stateMap = {
'downloading': 'Downloading',
'stalledDL': 'Downloading',
'metaDL': 'Downloading',
'forcedDL': 'Downloading',
'allocating': 'Downloading',
'uploading': 'Seeding',
'stalledUP': 'Seeding',
'forcedUP': 'Seeding',
'queuedUP': 'Queued',
'queuedDL': 'Queued',
'checkingUP': 'Checking',
'checkingDL': 'Checking',
'checkingResumeData': 'Checking',
'moving': 'Moving',
'pausedUP': 'Paused',
'pausedDL': 'Paused',
'stoppedUP': 'Stopped',
'stoppedDL': 'Stopped',
'error': 'Error',
'missingFiles': 'Error',
'unknown': 'Unknown'
};
const status = stateMap[torrent.state] || torrent.state;
return {
id: torrent.hash,
title: torrent.name,
type: 'torrent',
client: 'qbittorrent',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: totalSize,
downloaded: downloadedSize,
speed: torrent.dlspeed,
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
category: torrent.category || undefined,
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
savePath: torrent.content_path || torrent.save_path || undefined,
addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined,
raw: torrent
};
}
// Reset fallback flag (called by registry at start of each poll cycle)
resetFallbackFlag() {
this.fallbackThisCycle = false;
}
}
module.exports = QBittorrentClient;
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const xmlrpc = require('xmlrpc');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
/**
* rTorrent download client implementation.
* Communicates via XML-RPC over HTTP.
* Supports HTTP Basic Auth when username/password are configured.
* The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc).
*/
class RTorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this._createClient();
}
_createClient() {
const clientOptions = { url: this.url };
if (this.username && this.password) {
clientOptions.headers = {
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`
};
}
this.client = xmlrpc.createClient(clientOptions);
}
getClientType() {
return 'rtorrent';
}
async testConnection() {
try {
await this._methodCall('system.client_version');
logToFile(`[rtorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
/**
* Wrap xmlrpc methodCall in a Promise.
* @param {string} method - XML-RPC method name
* @param {Array} params - Method parameters
* @returns {Promise<any>}
*/
_methodCall(method, params = []) {
return new Promise((resolve, reject) => {
this.client.methodCall(method, params, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
}
async getActiveDownloads() {
try {
const torrents = await this._methodCall('d.multicall2', [
'',
'd.hash=',
'd.name=',
'd.size_bytes=',
'd.completed_bytes=',
'd.down.rate=',
'd.up.rate=',
'd.state=',
'd.is_active=',
'd.is_hash_checking=',
'd.directory=',
'd.custom1='
]);
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const [downRate, upRate] = await Promise.all([
this._methodCall('throttle.global_down.rate'),
this._methodCall('throttle.global_up.rate')
]);
return {
globalDownRate: downRate,
globalUpRate: upRate
};
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const [
hash,
name,
sizeBytes,
completedBytes,
downRate,
upRate,
state,
isActive,
isHashChecking,
directory,
custom1
] = torrent;
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
// Calculate ETA when actively downloading
let eta = null;
if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) {
eta = Math.round((sizeBytes - completedBytes) / downRate);
}
const arrInfo = this._extractArrInfo(name);
return {
id: hash,
title: name,
type: 'torrent',
client: 'rtorrent',
instanceId: this.id,
instanceName: this.name,
status,
progress,
size: sizeBytes,
downloaded: completedBytes,
speed: status === 'Seeding' ? upRate : downRate,
eta,
category: custom1 || undefined,
tags: custom1 ? [custom1] : [],
savePath: directory || undefined,
addedOn: undefined, // rtorrent does not expose added time via multicall2
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
_mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) {
if (isHashChecking === 1) {
return 'Checking';
}
if (state === 0) {
return 'Stopped';
}
if (isActive === 1) {
return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading';
}
return 'Paused';
}
_extractArrInfo(filename) {
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = RTorrentClient;
+239
View File
@@ -0,0 +1,239 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class SABnzbdClient extends DownloadClient {
constructor(instance) {
super(instance);
}
getClientType() {
return 'sabnzbd';
}
async testConnection() {
try {
const response = await this.makeRequest('', { mode: 'version' });
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
return true;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(additionalParams = {}, config = {}) {
const params = {
output: 'json',
apikey: this.apiKey,
...additionalParams
};
try {
const response = await axios.get(`${this.url}/api`, {
params,
...config
});
return response;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] API request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 })
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
for (const slot of queueData.queue.slots) {
downloads.push(this.normalizeDownload(slot, 'queue'));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history'));
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) return null;
return {
status: queueData.status,
speed: queueData.speed,
kbpersec: queueData.kbpersec,
sizeleft: queueData.sizeleft,
mbleft: queueData.mbleft,
mb: queueData.mb,
diskspace1: queueData.diskspace1,
diskspace2: queueData.diskspace2,
loadavg: queueData.loadavg,
pause_int: queueData.pause_int
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(slot, source) {
const isHistory = source === 'history';
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
'Paused': 'Paused',
'Waiting': 'Queued',
'Completed': 'Completed',
'Failed': 'Error',
'Verifying': 'Checking',
'Extracting': 'Extracting',
'Moving': 'Moving',
'QuickCheck': 'Checking',
'Repairing': 'Repairing'
};
const status = statusMap[slot.status] || slot.status;
// Calculate progress
let progress = 0;
let downloaded = 0;
let size = 0;
if (slot.mb && slot.mbleft !== undefined) {
size = slot.mb * 1024 * 1024; // Convert MB to bytes
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 0;
} else if (slot.size) {
// Try to parse size string (e.g., "1.5 GB")
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
if (sizeMatch) {
const [, sizeValue, sizeUnit] = sizeMatch;
const multiplier = this.getUnitMultiplier(sizeUnit);
size = parseFloat(sizeValue) * multiplier;
if (slot.sizeleft) {
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
if (leftMatch) {
const [, leftValue, leftUnit] = leftMatch;
const leftMultiplier = this.getUnitMultiplier(leftUnit);
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
progress = size > 0 ? (downloaded / size) * 100 : 0;
}
}
}
}
// Extract Sonarr/Radarr info from nzb_name if present
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
return {
id: slot.nzo_id || slot.id,
title: slot.filename || slot.nzb_name || 'Unknown',
type: 'usenet',
client: 'sabnzbd',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: { ...slot, source }
};
}
getUnitMultiplier(unit) {
const unitMap = {
'b': 1,
'byte': 1,
'bytes': 1,
'kb': 1024,
'k': 1024,
'mb': 1024 * 1024,
'm': 1024 * 1024,
'gb': 1024 * 1024 * 1024,
'g': 1024 * 1024 * 1024,
'tb': 1024 * 1024 * 1024 * 1024,
't': 1024 * 1024 * 1024 * 1024
};
return unitMap[unit.toLowerCase()] || 1;
}
calculateEta(timeLeft) {
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
return null;
}
// Parse time in various formats: "0:05:30", "15:30", "330"
const parts = timeLeft.split(':').reverse();
let totalSeconds = 0;
if (parts.length === 1) {
// Just seconds
totalSeconds = parseInt(parts[0], 10);
} else if (parts.length === 2) {
// MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
} else if (parts.length === 3) {
// HH:MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
}
return isNaN(totalSeconds) ? null : totalSeconds;
}
extractArrInfo(filename) {
// Try to extract Sonarr/Radarr info from filename patterns
// This is a simple implementation - could be enhanced with regex patterns
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = SABnzbdClient;
+181
View File
@@ -0,0 +1,181 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class TransmissionClient extends DownloadClient {
constructor(instance) {
super(instance);
this.sessionId = null;
this.rpcUrl = `${this.url}/transmission/rpc`;
}
getClientType() {
return 'transmission';
}
async testConnection() {
try {
await this.makeRequest('session-get');
logToFile(`[Transmission:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(method, arguments_ = {}, config = {}) {
const payload = {
method,
arguments: arguments_
};
const headers = {
'Content-Type': 'application/json'
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
try {
const response = await axios.post(this.rpcUrl, payload, {
headers,
...config
});
if (response.data.result !== 'success') {
throw new Error(`Transmission RPC error: ${response.data.result}`);
}
return response;
} catch (error) {
// Handle session ID conflict (409 Conflict)
if (error.response && error.response.status === 409) {
const sessionId = error.response.headers['x-transmission-session-id'];
if (sessionId) {
this.sessionId = sessionId;
logToFile(`[Transmission:${this.name}] Updated session ID`);
return this.makeRequest(method, arguments_, config);
}
}
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get all torrents with detailed fields
const response = await this.makeRequest('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
const torrents = response.data.arguments.torrents || [];
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('session-get');
const sessionStats = await this.makeRequest('session-stats');
return {
session: response.data.arguments,
stats: sessionStats.data.arguments
};
} catch (error) {
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
// Map Transmission status codes to normalized status
const statusMap = {
0: 'Stopped', // TORRENT_STOPPED
1: 'Queued', // TORRENT_CHECK_WAIT
2: 'Checking', // TORRENT_CHECK
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
4: 'Downloading', // TORRENT_DOWNLOAD
5: 'Queued', // TORRENT_SEED_WAIT
6: 'Seeding', // TORRENT_SEED
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
};
const status = statusMap[torrent.status] || 'Unknown';
// Calculate progress and sizes
const progress = torrent.percentDone * 100;
const size = torrent.totalSize;
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
let eta = null;
if (torrent.eta >= 0) {
eta = torrent.eta;
}
// Extract category/labels
const labels = torrent.labels || [];
const category = labels.length > 0 ? labels[0] : undefined;
// Try to extract Sonarr/Radarr info from name
const arrInfo = this.extractArrInfo(torrent.name);
return {
id: torrent.hashString,
title: torrent.name,
type: 'torrent',
client: 'transmission',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: size,
downloaded: downloaded,
speed: torrent.rateDownload,
eta: eta,
category: category,
tags: labels,
savePath: torrent.downloadDir || undefined,
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
extractArrInfo(filename) {
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = TransmissionClient;
+3 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2025 Gordon Bolton. MIT License. // Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
@@ -84,6 +84,7 @@ const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard'); const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history'); const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf'); const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'); const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config'); const { validateInstanceUrl } = require('./utils/config');
@@ -252,6 +253,7 @@ function serveIndex(req, res) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
app.use('/api', apiLimiter); app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// All routes below this point require CSRF validation on mutating methods // All routes below this point require CSRF validation on mutating methods
app.use('/api', verifyCsrf); app.use('/api', verifyCsrf);
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET; const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user; const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* CSRF protection using the double-submit cookie pattern. * CSRF protection using the double-submit cookie pattern.
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const crypto = require('crypto'); const crypto = require('crypto');
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const axios = require('axios'); const axios = require('axios');
+148
View File
@@ -1,8 +1,10 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError'); const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
router.use(requireAuth); router.use(requireAuth);
@@ -55,4 +57,150 @@ router.get('/movies', async (req, res) => {
} }
}); });
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
}
});
// PUT /api/radarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/radarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.RADARR_URL}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
);
res.json(response.data);
}
} catch (error) {
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router; module.exports = router;
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
+148
View File
@@ -1,8 +1,10 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError'); const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
router.use(requireAuth); router.use(requireAuth);
@@ -55,4 +57,150 @@ router.get('/series', async (req, res) => {
} }
}); });
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
}
});
// PUT /api/sonarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/sonarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.SONARR_URL}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
);
res.json(response.data);
}
} catch (error) {
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router; module.exports = router;
+317
View File
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const rateLimit = require('express-rate-limit');
const { logToFile } = require('../utils/logger');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
const router = express.Router();
// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter.
// Sonarr/Radarr send at most one event per action; 60/min per IP is generous.
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
const webhookLimiter = rateLimit({
windowMs: 60 * 1000,
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many webhook requests' }
});
// Valid *arr eventType strings — used for strict input validation.
const VALID_EVENT_TYPES = new Set([
'Test',
'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired',
'DownloadFolderImported', 'ImportFailed',
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
// *arr sends a `date` field on every event; we use it as the replay key component.
// TTL = 5 minutes; an event replayed after that window is considered fresh.
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
const recentEvents = new Map();
function pruneReplayCache() {
const cutoff = Date.now() - REPLAY_WINDOW_MS;
for (const [key, ts] of recentEvents) {
if (ts < cutoff) recentEvents.delete(key);
}
}
function isReplay(eventType, instanceName, eventDate) {
if (!eventDate) return false;
pruneReplayCache();
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
if (recentEvents.has(key)) return true;
recentEvents.set(key, Date.now());
return false;
}
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// Event classification — determines which cache keys to refresh
const QUEUE_EVENTS = new Set([
'Grab',
'Download',
'DownloadFailed',
'ManualInteractionRequired'
]);
const HISTORY_EVENTS = new Set([
'DownloadFolderImported',
'ImportFailed',
'EpisodeFileRenamed',
'MovieFileRenamed',
'EpisodeFileRenamedBySeries'
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* @param {Object} req - Express request object
* @returns {boolean} True if secret is valid, false otherwise
*/
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
return false;
}
if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
return false;
}
if (providedSecret !== expectedSecret) {
logToFile('[Webhook] WARNING: Invalid webhook secret provided');
return false;
}
return true;
}
/**
* Process a webhook event by refreshing the affected cache and broadcasting SSE.
* This is a fire-and-forget background task callers must respond to the webhook
* sender before awaiting this function.
*
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
*
* @param {string} serviceType - 'sonarr' or 'radarr'
* @param {string} eventType - the eventType from the *arr webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
const affectsQueue = QUEUE_EVENTS.has(eventType);
const affectsHistory = HISTORY_EVENTS.has(eventType);
if (!affectsQueue && !affectsHistory) {
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
return;
}
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`);
// Ensure retrievers are initialized (idempotent)
await arrRetrieverRegistry.initialize();
if (serviceType === 'sonarr') {
const sonarrInstances = getSonarrInstances();
if (affectsQueue) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const sonarrQueues = queuesByType.sonarr || [];
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
}
if (affectsHistory) {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
const sonarrHistories = historyByType.sonarr || [];
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`);
}
} else if (serviceType === 'radarr') {
const radarrInstances = getRadarrInstances();
if (affectsQueue) {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
const radarrQueues = queuesByType.radarr || [];
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
}
if (affectsHistory) {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
const radarrHistories = historyByType.radarr || [];
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
}
}
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
// pollAllServices() refreshes all data, updates every cache key, and then
// iterates pollSubscribers to push fresh payloads to every open SSE connection.
// If a poll is already in progress this call is a no-op, but the cache keys
// above were already updated so the next broadcast (or dashboard request)
// will see fresh data.
logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()');
await pollAllServices();
}
/**
* Validate and sanitize the incoming webhook payload.
* Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }.
*/
function validatePayload(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { valid: false, reason: 'Payload must be a JSON object' };
}
const { eventType, instanceName } = body;
if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) {
return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' };
}
if (!VALID_EVENT_TYPES.has(eventType)) {
return { valid: false, reason: `Unknown eventType: ${eventType}` };
}
if (instanceName !== undefined && typeof instanceName !== 'string') {
return { valid: false, reason: 'instanceName must be a string if provided' };
}
const eventDate = body.date || null;
return { valid: true, eventType, instanceName: instanceName || null, eventDate };
}
/**
* POST /api/webhook/sonarr
* Receives webhook events from Sonarr instances.
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
*
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
* Phase 6: rate limiting, input validation, replay protection.
*/
router.post('/sonarr', webhookLimiter, (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validatePayload(req.body);
if (!validation.valid) {
logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`);
return res.status(400).json({ error: validation.reason });
}
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
const sonarrInstances = getSonarrInstances();
const instance = sonarrInstances.find(i => i.name === instanceName);
if (instance) {
cache.updateWebhookMetrics(instance.url);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('sonarr', eventType).catch(err => {
logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Sonarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
/**
* POST /api/webhook/radarr
* Receives webhook events from Radarr instances.
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
*
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
* Phase 6: rate limiting, input validation, replay protection.
*/
router.post('/radarr', webhookLimiter, (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validatePayload(req.body);
if (!validation.valid) {
logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`);
return res.status(400).json({ error: validation.reason });
}
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
const radarrInstances = getRadarrInstances();
const instance = radarrInstances.find(i => i.name === instanceName);
if (instance) {
cache.updateWebhookMetrics(instance.url);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('radarr', eventType).catch(err => {
logToFile(`[Webhook] Radarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Radarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
module.exports = router;
+307
View File
@@ -0,0 +1,307 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const {
getSonarrInstances,
getRadarrInstances
} = require('./config');
// Import retriever classes
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
// Retriever type mapping
const retrieverClasses = {
sonarr: PollingSonarrRetriever,
radarr: PollingRadarrRetriever
};
/**
* Singleton registry for *arr data retrievers
*/
const arrRetrieverRegistry = {
retrievers: new Map(),
initialized: false,
/**
* Initialize all configured *arr retrievers
*/
async initialize() {
if (this.initialized) {
return;
}
logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...');
// Get all instance configurations
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Create retriever instances
const instanceConfigs = [
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
];
for (const config of instanceConfigs) {
try {
const RetrieverClass = retrieverClasses[config.type];
if (!RetrieverClass) {
logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`);
continue;
}
const retriever = new RetrieverClass(config);
this.retrievers.set(config.id, retriever);
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
},
/**
* Get all registered retrievers
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getAllRetrievers() {
return Array.from(this.retrievers.values());
},
/**
* Get retriever by instance ID
* @param {string} instanceId - The instance ID
* @returns {ArrRetriever|null} Retriever instance or null if not found
*/
getRetriever(instanceId) {
return this.retrievers.get(instanceId) || null;
},
/**
* Get retrievers by type
* @param {string} type - Retriever type ('sonarr', 'radarr')
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getRetrieversByType(type) {
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
},
/**
* Get tags from all retrievers
* @returns {Promise<Array<Object>>} Array of tag results with instance info
*/
async getAllTags() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch tags from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get queue from all retrievers
* @returns {Promise<Array<Object>>} Array of queue results with instance info
*/
async getAllQueues() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch queues from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get history from all retrievers
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Array<Object>>} Array of history results with instance info
*/
async getAllHistory(options = {}) {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch history from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get tags grouped by retriever type
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
*/
async getTagsByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrTags = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
const radarrTags = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return {
sonarr: sonarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get queue grouped by retriever type
* @returns {Promise<Object>} Queue grouped by retriever type
*/
async getQueuesByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrQueues = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrQueues = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get history grouped by retriever type
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Object>} History grouped by retriever type
*/
async getHistoryByType(options = {}) {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrHistory = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrHistory = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
}
};
module.exports = arrRetrieverRegistry;
+61
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger'); const { logToFile } = require('./logger');
class MemoryCache { class MemoryCache {
@@ -71,4 +72,64 @@ class MemoryCache {
const cache = new MemoryCache(); const cache = new MemoryCache();
// Webhook metrics for polling optimization
// These are stored separately from regular cache entries
const webhookMetrics = {
// Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped }
instances: new Map(),
// Global metrics
lastGlobalWebhookTimestamp: null,
totalWebhookEventsReceived: 0
};
function getWebhookMetrics(instanceUrl) {
if (!instanceUrl) return null;
return webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
}
function updateWebhookMetrics(instanceUrl) {
const now = Date.now();
webhookMetrics.lastGlobalWebhookTimestamp = now;
webhookMetrics.totalWebhookEventsReceived++;
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.lastWebhookTimestamp = now;
metrics.eventsReceived++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function incrementPollsSkipped(instanceUrl) {
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.pollsSkipped++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function getGlobalWebhookMetrics() {
return {
lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp,
totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived,
instances: Object.fromEntries(webhookMetrics.instances)
};
}
module.exports = cache; module.exports = cache;
module.exports.getWebhookMetrics = getWebhookMetrics;
module.exports.updateWebhookMetrics = updateWebhookMetrics;
module.exports.incrementPollsSkipped = incrementPollsSkipped;
module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics;
+33 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2025 Gordon Bolton. MIT License. // Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger'); const { logToFile } = require('./logger');
// Validate that a configured service URL is well-formed and uses http(s). // Validate that a configured service URL is well-formed and uses http(s).
@@ -94,11 +94,43 @@ function getQbittorrentInstances() {
); );
} }
function getTransmissionInstances() {
return parseInstances(
process.env.TRANSMISSION_INSTANCES,
process.env.TRANSMISSION_URL,
null, // no apiKey for Transmission
process.env.TRANSMISSION_USERNAME,
process.env.TRANSMISSION_PASSWORD
);
}
function getRtorrentInstances() {
return parseInstances(
process.env.RTORRENT_INSTANCES,
process.env.RTORRENT_URL,
null, // no apiKey for rtorrent
process.env.RTORRENT_USERNAME,
process.env.RTORRENT_PASSWORD
);
}
function getWebhookSecret() {
return process.env.SOFARR_WEBHOOK_SECRET || '';
}
function getSofarrBaseUrl() {
return process.env.SOFARR_BASE_URL || '';
}
module.exports = { module.exports = {
getSABnzbdInstances, getSABnzbdInstances,
getSonarrInstances, getSonarrInstances,
getRadarrInstances, getRadarrInstances,
getQbittorrentInstances, getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances,
getWebhookSecret,
getSofarrBaseUrl,
parseInstances, parseInstances,
validateInstanceUrl validateInstanceUrl
}; };
+254
View File
@@ -0,0 +1,254 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const {
getSABnzbdInstances,
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances
} = require('./config');
// Import client classes
const SABnzbdClient = require('../clients/SABnzbdClient');
const QBittorrentClient = require('../clients/QBittorrentClient');
const TransmissionClient = require('../clients/TransmissionClient');
const RTorrentClient = require('../clients/RTorrentClient');
// Client type mapping
const clientClasses = {
sabnzbd: SABnzbdClient,
qbittorrent: QBittorrentClient,
transmission: TransmissionClient,
rtorrent: RTorrentClient
};
/**
* Registry and factory for download clients
*/
class DownloadClientRegistry {
constructor() {
this.clients = new Map();
this.initialized = false;
}
/**
* Initialize all configured download clients
*/
async initialize() {
if (this.initialized) {
return;
}
logToFile('[DownloadClientRegistry] Initializing download clients...');
// Get all instance configurations
const sabnzbdInstances = getSABnzbdInstances();
const qbittorrentInstances = getQbittorrentInstances();
const transmissionInstances = getTransmissionInstances();
const rtorrentInstances = getRtorrentInstances();
// Create client instances
const instanceConfigs = [
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
...rtorrentInstances.map(inst => ({ ...inst, type: 'rtorrent' }))
];
for (const config of instanceConfigs) {
try {
const ClientClass = clientClasses[config.type];
if (!ClientClass) {
logToFile(`[DownloadClientRegistry] Unknown client type: ${config.type}`);
continue;
}
const client = new ClientClass(config);
this.clients.set(config.id, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
} catch (error) {
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[DownloadClientRegistry] Initialized ${this.clients.size} download clients`);
}
/**
* Get all registered clients
* @returns {Array<DownloadClient>} Array of client instances
*/
getAllClients() {
return Array.from(this.clients.values());
}
/**
* Get client by instance ID
* @param {string} instanceId - The instance ID
* @returns {DownloadClient|null} Client instance or null if not found
*/
getClient(instanceId) {
return this.clients.get(instanceId) || null;
}
/**
* Get clients by type
* @param {string} type - Client type ('sabnzbd', 'qbittorrent', 'transmission')
* @returns {Array<DownloadClient>} Array of client instances
*/
getClientsByType(type) {
return this.getAllClients().filter(client => client.getClientType() === type);
}
/**
* Get active downloads from all clients
* @returns {Promise<Array<NormalizedDownload>>} Array of all downloads
*/
async getAllDownloads() {
const clients = this.getAllClients();
if (clients.length === 0) {
return [];
}
// Reset fallback flags for qBittorrent clients
for (const client of clients) {
if (client.resetFallbackFlag) {
client.resetFallbackFlag();
}
}
// Fetch downloads from all clients in parallel
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const downloads = await client.getActiveDownloads();
logToFile(`[DownloadClientRegistry] ${client.name}: ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
return [];
}
})
);
// Flatten and return all downloads
const allDownloads = results
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value);
logToFile(`[DownloadClientRegistry] Total downloads from all clients: ${allDownloads.length}`);
return allDownloads;
}
/**
* Get downloads grouped by client type (for backward compatibility)
* @returns {Promise<Object>} Downloads grouped by client type
*/
async getDownloadsByClientType() {
const clients = this.getAllClients();
const result = {};
// Group by client type
for (const client of clients) {
const type = client.getClientType();
if (!result[type]) {
result[type] = [];
}
try {
const downloads = await client.getActiveDownloads();
result[type].push(...downloads);
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
}
}
return result;
}
/**
* Test connection to all clients
* @returns {Promise<Array<Object>>} Array of connection test results
*/
async testAllConnections() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const success = await client.testConnection();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success,
error: null
};
} catch (error) {
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success: false,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
/**
* Get client status information from all clients
* @returns {Promise<Array<Object>>} Array of client status objects
*/
async getAllClientStatuses() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const status = await client.getClientStatus();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status
};
} catch (error) {
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status: null,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
}
// Create singleton instance
const registry = new DownloadClientRegistry();
module.exports = {
DownloadClientRegistry,
registry,
// Convenience functions
initializeClients: () => registry.initialize(),
getAllClients: () => registry.getAllClients(),
getClient: (instanceId) => registry.getClient(instanceId),
getClientsByType: (type) => registry.getClientsByType(type),
getAllDownloads: () => registry.getAllDownloads(),
getDownloadsByClientType: () => registry.getDownloadsByClientType(),
testAllConnections: () => registry.testAllConnections(),
getAllClientStatuses: () => registry.getAllClientStatuses()
};
+35 -24
View File
@@ -1,6 +1,7 @@
const axios = require('axios'); // Copyright (c) 2026 Gordon Bolton. MIT License.
const cache = require('./cache'); const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config'); const { getSonarrInstances, getRadarrInstances } = require('./config');
const arrRetrieverRegistry = require('./arrRetrievers');
// Cache TTL for recent-history data: 5 minutes. // Cache TTL for recent-history data: 5 minutes.
// History changes slowly compared to active downloads. // History changes slowly compared to active downloads.
@@ -25,21 +26,26 @@ async function fetchSonarrHistory(since) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances(); const instances = getSonarrInstances();
const results = await Promise.all(instances.map(async inst => { const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try { try {
const response = await axios.get(`${inst.url}/api/v3/history`, { const response = await retriever.getHistory({
headers: { 'X-Api-Key': inst.apiKey }, pageSize: 100,
params: { sortKey: 'date',
pageSize: 100, sortDir: 'descending',
sortKey: 'date', includeSeries: true,
sortDir: 'descending', includeEpisode: true,
includeSeries: true, startDate: since.toISOString()
includeEpisode: true,
startDate: since.toISOString()
}
}); });
const records = (response.data && response.data.records) || []; const records = (response && response.records) || [];
return records.map(r => { return records.map(r => {
if (r.series) r.series._instanceUrl = inst.url; if (r.series) r.series._instanceUrl = inst.url;
if (r.series) r.series._instanceName = inst.name || inst.id; if (r.series) r.series._instanceName = inst.name || inst.id;
@@ -69,20 +75,25 @@ async function fetchRadarrHistory(since) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances(); const instances = getRadarrInstances();
const results = await Promise.all(instances.map(async inst => { const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try { try {
const response = await axios.get(`${inst.url}/api/v3/history`, { const response = await retriever.getHistory({
headers: { 'X-Api-Key': inst.apiKey }, pageSize: 100,
params: { sortKey: 'date',
pageSize: 100, sortDir: 'descending',
sortKey: 'date', includeMovie: true,
sortDir: 'descending', startDate: since.toISOString()
includeMovie: true,
startDate: since.toISOString()
}
}); });
const records = (response.data && response.data.records) || []; const records = (response && response.records) || [];
return records.map(r => { return records.map(r => {
if (r.movie) r.movie._instanceUrl = inst.url; if (r.movie) r.movie._instanceUrl = inst.url;
if (r.movie) r.movie._instanceName = inst.name || inst.id; if (r.movie) r.movie._instanceName = inst.name || inst.id;
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2025 Gordon Bolton. MIT License. // Copyright (c) 2026 Gordon Bolton. MIT License.
// //
// Docker secrets support: if an environment variable named FOO_FILE is set, // Docker secrets support: if an environment variable named FOO_FILE is set,
// read its contents from the file at that path and expose it as FOO. // read its contents from the file at that path and expose it as FOO.
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+211 -126
View File
@@ -1,9 +1,9 @@
// Copyright (c) 2025 Gordon Bolton. MIT License. // Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios'); const axios = require('axios');
const cache = require('./cache'); const cache = require('./cache');
const { getTorrents } = require('./qbittorrent'); const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
const arrRetrieverRegistry = require('./arrRetrievers');
const { const {
getSABnzbdInstances,
getSonarrInstances, getSonarrInstances,
getRadarrInstances getRadarrInstances
} = require('./config'); } = require('./config');
@@ -14,6 +14,13 @@ const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false'
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000); : (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
const POLLING_ENABLED = POLL_INTERVAL > 0; const POLLING_ENABLED = POLL_INTERVAL > 0;
// Webhook fallback timeout in minutes (default 10)
const WEBHOOK_FALLBACK_TIMEOUT_MINUTES = parseInt(process.env.WEBHOOK_FALLBACK_TIMEOUT, 10) || 10;
const WEBHOOK_FALLBACK_TIMEOUT_MS = WEBHOOK_FALLBACK_TIMEOUT_MINUTES * 60 * 1000;
// Webhook poll interval multiplier when webhooks are active (default 3x)
const WEBHOOK_POLL_INTERVAL_MULTIPLIER = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MULTIPLIER, 10) || 3;
let polling = false; let polling = false;
let lastPollTimings = null; let lastPollTimings = null;
@@ -30,6 +37,42 @@ async function timed(label, fn) {
return { label, result, ms: Date.now() - t0 }; return { label, result, ms: Date.now() - t0 };
} }
// Helper function to determine if instance polling should be skipped
function shouldSkipInstancePolling(instances, instanceType) {
if (!instances || instances.length === 0) {
return false;
}
const now = Date.now();
let allInstancesHaveRecentWebhooks = true;
let skippedCount = 0;
for (const instance of instances) {
const metrics = cache.getWebhookMetrics(instance.url);
// Skip polling if:
// 1. Webhook events have been received (eventsReceived > 0)
// 2. Last webhook was recent (within fallback timeout)
// 3. Webhook has been enabled (we have metrics)
const hasWebhookActivity = metrics && metrics.eventsReceived > 0;
const isRecent = metrics && metrics.lastWebhookTimestamp && (now - metrics.lastWebhookTimestamp) < WEBHOOK_FALLBACK_TIMEOUT_MS;
if (hasWebhookActivity && isRecent) {
skippedCount++;
cache.incrementPollsSkipped(instance.url);
} else {
allInstancesHaveRecentWebhooks = false;
}
}
if (allInstancesHaveRecentWebhooks && skippedCount > 0) {
console.log(`[Poller] Skipping ${instanceType} polling for ${skippedCount} instance(s) with active webhooks`);
return true;
}
return false;
}
async function pollAllServices() { async function pollAllServices() {
if (polling) { if (polling) {
console.log('[Poller] Previous poll still running, skipping'); console.log('[Poller] Previous poll still running, skipping');
@@ -39,93 +82,65 @@ async function pollAllServices() {
const start = Date.now(); const start = Date.now();
try { try {
const sabInstances = getSABnzbdInstances(); // Ensure download clients and *arr retrievers are initialized
await initializeClients();
await arrRetrieverRegistry.initialize();
const sonarrInstances = getSonarrInstances(); const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances(); const radarrInstances = getRadarrInstances();
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
const globalMetrics = cache.getGlobalWebhookMetrics();
const now = Date.now();
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
if (fallbackTriggered) {
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
}
// Determine which instances should be polled based on webhook activity
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
// All fetches in parallel, each individually timed // All fetches in parallel, each individually timed
const results = await Promise.all([ const results = await Promise.all([
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst => timed('Download Clients', async () => {
axios.get(`${inst.url}/api`, { const downloadsByType = await getDownloadsByClientType();
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' } return downloadsByType;
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { }),
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message); shouldPollSonarr ? timed('Sonarr Tags', async () => {
return { instance: inst.id, data: { queue: { slots: [] } } }; const tagsByType = await arrRetrieverRegistry.getTagsByType();
}) return tagsByType.sonarr || [];
))), }) : timed('Sonarr Tags', async () => []),
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst => shouldPollSonarr ? timed('Sonarr Queue', async () => {
axios.get(`${inst.url}/api`, { const queuesByType = await arrRetrieverRegistry.getQueuesByType();
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 } return queuesByType.sonarr || [];
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { }) : timed('Sonarr Queue', async () => []),
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message); shouldPollSonarr ? timed('Sonarr History', async () => {
return { instance: inst.id, data: { history: { slots: [] } } }; const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
}) return historyByType.sonarr || [];
))), }) : timed('Sonarr History', async () => []),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst => shouldPollRadarr ? timed('Radarr Queue', async () => {
axios.get(`${inst.url}/api/v3/tag`, { const queuesByType = await arrRetrieverRegistry.getQueuesByType();
headers: { 'X-Api-Key': inst.apiKey } return queuesByType.radarr || [];
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { }) : timed('Radarr Queue', async () => []),
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message); shouldPollRadarr ? timed('Radarr History', async () => {
return { instance: inst.id, data: [] }; const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
}) return historyByType.radarr || [];
))), }) : timed('Radarr History', async () => []),
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst => shouldPollRadarr ? timed('Radarr Tags', async () => {
axios.get(`${inst.url}/api/v3/queue`, { const tagsByType = await arrRetrieverRegistry.getTagsByType();
headers: { 'X-Api-Key': inst.apiKey }, return tagsByType.radarr || [];
params: { includeSeries: true, includeEpisode: true } }) : timed('Radarr Tags', async () => []),
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10, includeEpisode: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('qBittorrent', () => getTorrents().catch(err => {
console.error(`[Poller] qBittorrent error:`, err.message);
return [];
}))
]); ]);
const [ const [
{ result: sabQueues }, { result: sabHistories }, { result: downloadsByType },
{ result: sonarrTagsResults }, { result: sonarrQueues }, { result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories }, { result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories }, { result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults }, { result: radarrTagsResults }
{ result: qbittorrentTorrents }
] = results; ] = results;
// Store per-task timings // Store per-task timings
@@ -140,60 +155,130 @@ async function pollAllServices() {
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry // When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// SABnzbd // Download Clients (SABnzbd, qBittorrent, Transmission)
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue; // Preserve backward compatibility with existing cache keys
const sabnzbdDownloads = downloadsByType.sabnzbd || [];
const qbittorrentDownloads = downloadsByType.qbittorrent || [];
// SABnzbd - separate queue and history based on source
const sabQueue = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'queue');
const sabHistory = sabnzbdDownloads.filter(d => d.raw && d.raw.source === 'history');
// Transform SABnzbd downloads to legacy format for cache
const sabQueueLegacy = {
slots: sabQueue.map(d => ({
nzo_id: d.id,
filename: d.title,
status: d.status,
progress: d.progress / 100,
mb: d.size / (1024 * 1024),
mbleft: (d.size - d.downloaded) / (1024 * 1024),
kbpersec: d.speed / 1024,
timeleft: d.eta ? `${Math.floor(d.eta / 60)}:${String(Math.floor(d.eta % 60)).padStart(2, '0')}` : 'unknown',
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
}))
};
const sabHistoryLegacy = {
slots: sabHistory.map(d => ({
nzo_id: d.id,
filename: d.title,
status: d.status,
mb: d.size / (1024 * 1024),
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
}))
};
// Extract status from first SABnzbd download if available
const firstSabDownload = sabQueue[0];
const sabStatus = firstSabDownload ? {
status: 'Active',
speed: firstSabDownload.speed,
kbpersec: firstSabDownload.speed / 1024
} : { status: 'Idle', speed: 0, kbpersec: 0 };
cache.set('poll:sab-queue', { cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []), ...sabQueueLegacy,
status: firstSabQueue && firstSabQueue.status, ...sabStatus
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}, cacheTTL); }, cacheTTL);
cache.set('poll:sab-history', sabHistoryLegacy, cacheTTL);
// qBittorrent - transform to legacy format
const qbittorrentLegacy = qbittorrentDownloads.map(d => ({
...d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}));
cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL);
// Sonarr // Sonarr
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL); if (shouldPollSonarr) {
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
cache.set('poll:sonarr-queue', { // Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
records: sonarrQueues.flatMap(q => { cache.set('poll:sonarr-queue', {
const inst = sonarrInstances.find(i => i.id === q.instance); records: sonarrQueues.flatMap(q => {
const url = inst ? inst.url : null; const inst = sonarrInstances.find(i => i.id === q.instance);
const key = inst ? inst.apiKey : null; const url = inst ? inst.url : null;
return (q.data.records || []).map(r => { const key = inst ? inst.apiKey : null;
if (r.series) r.series._instanceUrl = url; return (q.data.records || []).map(r => {
r._instanceUrl = url; if (r.series) r.series._instanceUrl = url;
r._instanceKey = key; r._instanceUrl = url;
return r; r._instanceKey = key;
}); return r;
}) });
}, cacheTTL); })
cache.set('poll:sonarr-history', { }, cacheTTL);
records: sonarrHistories.flatMap(h => h.data.records || []) cache.set('poll:sonarr-history', {
}, cacheTTL); records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
} else {
// Extend TTL of existing cached data when polling is skipped
const existingSonarrTags = cache.get('poll:sonarr-tags');
const existingSonarrQueue = cache.get('poll:sonarr-queue');
const existingSonarrHistory = cache.get('poll:sonarr-history');
if (existingSonarrTags) cache.set('poll:sonarr-tags', existingSonarrTags, cacheTTL);
if (existingSonarrQueue) cache.set('poll:sonarr-queue', existingSonarrQueue, cacheTTL);
if (existingSonarrHistory) cache.set('poll:sonarr-history', existingSonarrHistory, cacheTTL);
}
// Radarr // Radarr
cache.set('poll:radarr-queue', { if (shouldPollRadarr) {
records: radarrQueues.flatMap(q => { cache.set('poll:radarr-queue', {
const inst = radarrInstances.find(i => i.id === q.instance); records: radarrQueues.flatMap(q => {
const url = inst ? inst.url : null; const inst = radarrInstances.find(i => i.id === q.instance);
const key = inst ? inst.apiKey : null; const url = inst ? inst.url : null;
return (q.data.records || []).map(r => { const key = inst ? inst.apiKey : null;
if (r.movie) r.movie._instanceUrl = url; return (q.data.records || []).map(r => {
r._instanceUrl = url; if (r.movie) r.movie._instanceUrl = url;
r._instanceKey = key; r._instanceUrl = url;
return r; r._instanceKey = key;
}); return r;
}) });
}, cacheTTL); })
cache.set('poll:radarr-history', { }, cacheTTL);
records: radarrHistories.flatMap(h => h.data.records || []) cache.set('poll:radarr-history', {
}, cacheTTL); records: radarrHistories.flatMap(h => h.data.records || [])
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL); }, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
} else {
// Extend TTL of existing cached data when polling is skipped
const existingRadarrQueue = cache.get('poll:radarr-queue');
const existingRadarrHistory = cache.get('poll:radarr-history');
const existingRadarrTags = cache.get('poll:radarr-tags');
if (existingRadarrQueue) cache.set('poll:radarr-queue', existingRadarrQueue, cacheTTL);
if (existingRadarrHistory) cache.set('poll:radarr-history', existingRadarrHistory, cacheTTL);
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
}
// qBittorrent // qBittorrent (already set above in download clients section)
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
console.log(`[Poller] Poll complete in ${elapsed}ms`); console.log(`[Poller] Poll complete in ${elapsed}ms`);
+40 -125
View File
@@ -1,132 +1,47 @@
const axios = require('axios'); // Copyright (c) 2026 Gordon Bolton. MIT License.
// Legacy compatibility layer - delegates to new DownloadClient system
const { logToFile } = require('./logger'); const { logToFile } = require('./logger');
const { getQbittorrentInstances } = require('./config'); const { initializeClients, getClientsByType } = require('./downloadClients');
class QBittorrentClient {
constructor(instance) {
this.id = instance.id;
this.name = instance.name;
this.url = instance.url;
this.username = instance.username;
this.password = instance.password;
this.authCookie = null;
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
async getTorrents() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
// Add instance info to each torrent
return response.data.map(torrent => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
}
// Persist clients so auth cookies survive between requests
let persistedClients = null;
function getClients() {
if (persistedClients) return persistedClients;
const instances = getQbittorrentInstances();
if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured');
return [];
}
logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`);
persistedClients = instances.map(inst => new QBittorrentClient(inst));
return persistedClients;
}
/**
* Legacy function for backward compatibility
* Returns all torrents from all qBittorrent instances
*/
async function getAllTorrents() { async function getAllTorrents() {
const clients = getClients(); try {
if (clients.length === 0) { await initializeClients();
const clients = getClientsByType('qbittorrent');
if (clients.length === 0) {
logToFile('[qBittorrent] No instances configured');
return [];
}
const results = await Promise.all(
clients.map(client => client.getActiveDownloads().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
return [];
}))
);
const allTorrents = results.flat();
// Convert back to legacy format for backward compatibility
const legacyTorrents = allTorrents.map(download => download.raw);
logToFile(`[qBittorrent] Total torrents from all instances: ${legacyTorrents.length}`);
return legacyTorrents;
} catch (error) {
logToFile(`[qBittorrent] Error in getAllTorrents: ${error.message}`);
return []; return [];
} }
}
const results = await Promise.all(
clients.map(client => client.getTorrents().catch(err => { /**
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`); * Legacy function for backward compatibility
return []; */
})) function getClients() {
); logToFile('[qBittorrent] getClients() called - delegating to new system');
return []; // Not used in new system
const allTorrents = results.flat();
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
return allTorrents;
} }
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -210,7 +125,7 @@ function mapTorrentToDownload(torrent) {
} }
module.exports = { module.exports = {
getTorrents: getAllTorrents, getAllTorrents,
getClients, getClients,
mapTorrentToDownload, mapTorrentToDownload,
formatBytes, formatBytes,
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2025 Gordon Bolton. MIT License. // Copyright (c) 2026 Gordon Bolton. MIT License.
// Query-param secrets (SABnzbd apikey, generic token/password params) // Query-param secrets (SABnzbd apikey, generic token/password params)
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi; const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization) // HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Persistent token store backed by a JSON file. * Persistent token store backed by a JSON file.
* *
+5 -1
View File
@@ -41,7 +41,10 @@ tests/
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry │ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
└── integration/ └── integration/
├── health.test.js # GET /health and /ready endpoints ├── health.test.js # GET /health and /ready endpoints
── auth.test.js # Full login/logout/me/csrf flows via supertest + nock ── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
# replay protection, metrics, security assertions
``` ```
## Key design decisions ## Key design decisions
@@ -60,6 +63,7 @@ The tested files meet these per-file minimums (enforced in CI):
|---|---|---| |---|---|---|
| `server/app.js` | 85% | 65% | | `server/app.js` | 85% | 65% |
| `server/routes/auth.js` | 85% | 70% | | `server/routes/auth.js` | 85% | 70% |
| `server/routes/webhook.js` | 80% | 70% |
| `server/middleware/requireAuth.js` | 75% | 80% | | `server/middleware/requireAuth.js` | 75% | 80% |
| `server/utils/sanitizeError.js` | 60% | — | | `server/utils/sanitizeError.js` | 60% | — |
| `server/utils/config.js` | 50% | 55% | | `server/utils/config.js` | 50% | 55% |
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Integration tests for authentication routes. * Integration tests for authentication routes.
* *
+300
View File
@@ -0,0 +1,300 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
initializeClients,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
registry
} from '../../server/utils/downloadClients.js';
import axios from 'axios';
import { vi } from 'vitest';
// Mock environment variables for testing
process.env.SABNZBD_INSTANCES = JSON.stringify([
{
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
}
]);
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb',
name: 'Test qBittorrent',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
}
]);
process.env.TRANSMISSION_INSTANCES = JSON.stringify([
{
id: 'test-trans',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
}
]);
process.env.RTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080/RPC2',
username: 'rtorrent',
password: 'rtorrent'
}
]);
// Mock axios to prevent actual network calls
vi.mock('axios', () => {
const mockAxios = vi.fn();
mockAxios.post = vi.fn();
mockAxios.get = vi.fn();
return {
default: mockAxios,
post: vi.fn(),
get: vi.fn()
};
});
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('Download Clients Integration Tests', () => {
beforeEach(() => {
registry.initialized = false;
registry.clients.clear();
vi.clearAllMocks();
});
describe('Client Initialization', () => {
it('should initialize all configured client types', async () => {
await initializeClients();
// The registry should have clients for all three types
const downloadsByType = await getDownloadsByClientType();
// Should have keys for each client type (even if empty due to mocked failures)
expect(typeof downloadsByType).toBe('object');
});
it('should handle missing environment variables gracefully', async () => {
// Temporarily clear environment variables
const originalSab = process.env.SABNZBD_INSTANCES;
const originalQb = process.env.QBITTORRENT_INSTANCES;
const originalTrans = process.env.TRANSMISSION_INSTANCES;
const originalRt = process.env.RTORRENT_INSTANCES;
delete process.env.SABNZBD_INSTANCES;
delete process.env.QBITTORRENT_INSTANCES;
delete process.env.TRANSMISSION_INSTANCES;
delete process.env.RTORRENT_INSTANCES;
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
expect(Object.keys(downloadsByType)).toHaveLength(0);
// Restore environment variables
process.env.SABNZBD_INSTANCES = originalSab;
process.env.QBITTORRENT_INSTANCES = originalQb;
process.env.TRANSMISSION_INSTANCES = originalTrans;
process.env.RTORRENT_INSTANCES = originalRt;
});
});
describe('Download Aggregation', () => {
it('should aggregate downloads from multiple client types', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
// Should return downloads grouped by type
expect(typeof downloadsByType).toBe('object');
// Should return flattened array of all downloads
expect(Array.isArray(allDownloads)).toBe(true);
// All downloads should have required normalized fields
allDownloads.forEach(download => {
expect(download).toHaveProperty('id');
expect(download).toHaveProperty('title');
expect(download).toHaveProperty('type');
expect(download).toHaveProperty('client');
expect(download).toHaveProperty('instanceId');
expect(download).toHaveProperty('instanceName');
expect(download).toHaveProperty('status');
expect(download).toHaveProperty('progress');
expect(download).toHaveProperty('size');
expect(download).toHaveProperty('downloaded');
expect(download).toHaveProperty('speed');
expect(download).toHaveProperty('raw');
});
});
it('should maintain type consistency across clients', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Check that each client type returns consistent data structure
Object.entries(downloadsByType).forEach(([clientType, downloads]) => {
if (downloads.length > 0) {
downloads.forEach(download => {
expect(download.client).toBe(clientType);
expect(download.type).toMatch(/^(usenet|torrent)$/);
expect(typeof download.progress).toBe('number');
expect(download.progress).toBeGreaterThanOrEqual(0);
expect(download.progress).toBeLessThanOrEqual(100);
expect(typeof download.size).toBe('number');
expect(typeof download.downloaded).toBe('number');
expect(typeof download.speed).toBe('number');
});
}
});
});
});
describe('Connection Testing', () => {
it('should test connections for all configured clients', async () => {
await initializeClients();
const results = await testAllConnections();
expect(Array.isArray(results)).toBe(true);
results.forEach(result => {
expect(result).toHaveProperty('instanceId');
expect(result).toHaveProperty('instanceName');
expect(result).toHaveProperty('clientType');
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
});
});
it('should handle connection failures gracefully', async () => {
// This test verifies that connection failures don't crash the system
await initializeClients();
const results = await testAllConnections();
// Should still return results even if connections fail
expect(results.length).toBeGreaterThan(0);
});
});
describe('Error Handling and Resilience', () => {
it('should handle individual client failures without affecting others', async () => {
await initializeClients();
// Even if some clients fail, others should still work
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
expect(typeof downloadsByType).toBe('object');
expect(Array.isArray(allDownloads)).toBe(true);
});
it('should handle malformed configuration gracefully', async () => {
// Test with malformed JSON
const originalSab = process.env.SABNZBD_INSTANCES;
process.env.SABNZBD_INSTANCES = 'invalid-json{';
// Should not throw an error
await expect(initializeClients()).resolves.not.toThrow();
// Restore
process.env.SABNZBD_INSTANCES = originalSab;
});
it('should handle network timeouts and errors', async () => {
await initializeClients();
// Mock network failures by setting up axios to reject
axios.get.mockRejectedValue(new Error('Network timeout'));
axios.post.mockRejectedValue(new Error('Network timeout'));
// Should handle errors gracefully and return empty results
const downloads = await getAllDownloads();
expect(Array.isArray(downloads)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should maintain compatibility with existing cache structure', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// SABnzbd downloads should have raw data for legacy compatibility
if (downloadsByType.sabnzbd && downloadsByType.sabnzbd.length > 0) {
downloadsByType.sabnzbd.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.source).toMatch(/^(queue|history)$/);
});
}
// qBittorrent downloads should have raw data for legacy compatibility
if (downloadsByType.qbittorrent && downloadsByType.qbittorrent.length > 0) {
downloadsByType.qbittorrent.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.hash).toBeTruthy(); // qBittorrent specific field
});
}
});
});
describe('Performance and Scalability', () => {
it('should handle multiple instances of the same client type', async () => {
// Configure multiple instances
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb-1',
name: 'Test qBittorrent 1',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
},
{
id: 'test-qb-2',
name: 'Test qBittorrent 2',
url: 'http://localhost:8081',
username: 'admin',
password: 'adminadmin'
}
]);
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Should aggregate downloads from both instances
expect(Array.isArray(downloadsByType.qbittorrent)).toBe(true);
// Each download should have correct instance information
downloadsByType.qbittorrent.forEach(download => {
expect(download.instanceId).toMatch(/^(test-qb-1|test-qb-2)$/);
expect(download.instanceName).toMatch(/^(Test qBittorrent 1|Test qBittorrent 2)$/);
});
});
it('should execute client requests in parallel', async () => {
const startTime = Date.now();
await initializeClients();
await getAllDownloads();
const endTime = Date.now();
const duration = endTime - startTime;
// This is a rough check - in a real scenario with actual network calls,
// parallel execution should be significantly faster than sequential
expect(duration).toBeGreaterThan(0);
});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Integration tests for health and readiness endpoints. * Integration tests for health and readiness endpoints.
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Integration tests for GET /api/history/recent * Integration tests for GET /api/history/recent
* *
+395
View File
@@ -0,0 +1,395 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for webhook endpoints:
* POST /api/webhook/sonarr
* POST /api/webhook/radarr
*
* Uses supertest against createApp() (no real server).
* processWebhookEvent() makes outbound *arr API calls those are blocked by
* nock so tests remain hermetic (fire-and-forget, not awaited by the handler).
*
* Covers:
* - 401 when X-Sofarr-Webhook-Secret is missing or wrong
* - 400 when payload is invalid (missing/unknown eventType, non-object body)
* - 200 + { received: true } for valid events
* - Replay protection: second identical event returns { duplicate: true }
* - Test event (eventType=Test) is accepted and short-circuits the cache refresh
* - cache.updateWebhookMetrics is called when a known instance name is provided
* - cache.getGlobalWebhookMetrics reflects the recorded event
*/
import request from 'supertest';
import nock from 'nock';
import { beforeEach, afterEach } from 'vitest';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
const VALID_SECRET = 'test-webhook-secret-abc';
// Minimal valid Sonarr Grab payload
const SONARR_GRAB = {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T10:00:00.000Z',
series: { id: 1, title: 'Test Show' },
episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }]
};
// Minimal valid Radarr Grab payload
const RADARR_GRAB = {
eventType: 'Grab',
instanceName: 'Main Radarr',
date: '2026-05-19T10:00:01.000Z',
movie: { id: 1, title: 'Test Movie' }
};
// Minimal Test event (sent by *arr "Test" button in notifications settings)
const SONARR_TEST = {
eventType: 'Test',
instanceName: 'Main Sonarr',
date: '2026-05-19T10:00:02.000Z'
};
function makeApp() {
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
]);
process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
]);
return createApp({ skipRateLimits: true });
}
function postSonarr(app, payload, secret = VALID_SECRET) {
const req = request(app).post('/api/webhook/sonarr').send(payload);
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
return req;
}
function postRadarr(app, payload, secret = VALID_SECRET) {
const req = request(app).post('/api/webhook/radarr').send(payload);
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
return req;
}
beforeEach(() => {
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
});
afterEach(() => {
nock.cleanAll();
delete process.env.SOFARR_WEBHOOK_SECRET;
});
// ---------------------------------------------------------------------------
// Secret validation
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — secret validation', () => {
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
const app = makeApp();
const res = await postSonarr(app, SONARR_GRAB, null);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
const app = makeApp();
const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
delete process.env.SOFARR_WEBHOOK_SECRET;
const app = createApp({ skipRateLimits: true });
const res = await postSonarr(app, SONARR_GRAB, 'anything');
expect(res.status).toBe(401);
});
});
describe('POST /api/webhook/radarr — secret validation', () => {
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, null);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
expect(res.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — input validation', () => {
it('returns 400 when body is not a JSON object (array)', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr')
.set('X-Sofarr-Webhook-Secret', VALID_SECRET)
.send([{ eventType: 'Grab' }]);
expect(res.status).toBe(400);
});
it('returns 400 when eventType is missing', async () => {
const app = makeApp();
const res = await postSonarr(app, { instanceName: 'Main Sonarr' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/eventType/);
});
it('returns 400 when eventType is an unknown value', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown eventType/);
});
it('returns 400 when eventType is not a string', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 42 });
expect(res.status).toBe(400);
});
it('returns 400 when eventType exceeds 64 characters', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'G'.repeat(65) });
expect(res.status).toBe(400);
});
it('returns 400 when instanceName is not a string', async () => {
const app = makeApp();
const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/instanceName/);
});
});
describe('POST /api/webhook/radarr — input validation', () => {
it('returns 400 when eventType is missing', async () => {
const app = makeApp();
const res = await postRadarr(app, { instanceName: 'Main Radarr' });
expect(res.status).toBe(400);
});
it('returns 400 when eventType is unknown', async () => {
const app = makeApp();
const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Unknown eventType/);
});
});
// ---------------------------------------------------------------------------
// Happy path — valid events
// ---------------------------------------------------------------------------
describe('POST /api/webhook/sonarr — valid events', () => {
it('returns 200 { received: true } for a valid Grab event', async () => {
const app = makeApp();
const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' };
const res = await postSonarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
expect(res.body.duplicate).toBeUndefined();
});
it('returns 200 { received: true } for a Test event', async () => {
const app = makeApp();
const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' };
const res = await postSonarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts DownloadFolderImported event', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'DownloadFolderImported',
instanceName: 'Main Sonarr',
date: '2026-05-19T11:02:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts event without instanceName field', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
date: '2026-05-19T11:03:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
});
describe('POST /api/webhook/radarr — valid events', () => {
it('returns 200 { received: true } for a valid Grab event', async () => {
const app = makeApp();
const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' };
const res = await postRadarr(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('accepts Download event', async () => {
const app = makeApp();
const res = await postRadarr(app, {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T12:01:00.000Z'
});
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Replay protection
// ---------------------------------------------------------------------------
describe('Replay protection', () => {
it('sonarr: second identical event (same date) returns duplicate:true', async () => {
const app = makeApp();
const payload = {
eventType: 'Grab',
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).toBe(true);
});
it('sonarr: event with different date is not considered a duplicate', async () => {
const app = makeApp();
const first = await postSonarr(app, {
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z'
});
expect(first.body.duplicate).toBeUndefined();
const second = await postSonarr(app, {
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z'
});
expect(second.body.duplicate).toBeUndefined();
});
it('radarr: second identical event returns duplicate:true', async () => {
const app = makeApp();
const payload = {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T15:00:00.000Z'
};
await postRadarr(app, payload);
const second = await postRadarr(app, payload);
expect(second.body.duplicate).toBe(true);
});
it('event without date field is never considered a duplicate', async () => {
const app = makeApp();
const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' };
const first = await postSonarr(app, payload);
const second = await postSonarr(app, payload);
// Neither should be flagged as duplicate (no date = no replay key)
expect(first.body.duplicate).toBeUndefined();
expect(second.body.duplicate).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Webhook metrics (Phase 5.1 integration)
// ---------------------------------------------------------------------------
describe('Webhook metrics — cache.updateWebhookMetrics integration', () => {
it('sonarr: increments eventsReceived for a known instance', async () => {
const app = makeApp();
const instanceUrl = 'https://sonarr.test';
const before = cache.getWebhookMetrics(instanceUrl);
const countBefore = before ? before.eventsReceived : 0;
await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T16:00:00.000Z'
});
const after = cache.getWebhookMetrics(instanceUrl);
expect(after.eventsReceived).toBe(countBefore + 1);
expect(after.lastWebhookTimestamp).toBeGreaterThan(0);
});
it('radarr: increments eventsReceived for a known instance', async () => {
const app = makeApp();
const instanceUrl = 'https://radarr.test';
const before = cache.getWebhookMetrics(instanceUrl);
const countBefore = before ? before.eventsReceived : 0;
await postRadarr(app, {
eventType: 'Download',
instanceName: 'Main Radarr',
date: '2026-05-19T16:01:00.000Z'
});
const after = cache.getWebhookMetrics(instanceUrl);
expect(after.eventsReceived).toBe(countBefore + 1);
});
it('does not crash when instanceName does not match a configured instance', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Unknown Instance',
date: '2026-05-19T16:02:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('global metrics totalWebhookEventsReceived increments after valid event', async () => {
const app = makeApp();
const beforeGlobal = cache.getGlobalWebhookMetrics();
const beforeCount = beforeGlobal.totalWebhookEventsReceived;
await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T17:00:00.000Z'
});
const afterGlobal = cache.getGlobalWebhookMetrics();
expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1);
});
});
// ---------------------------------------------------------------------------
// Secret not included in response
// ---------------------------------------------------------------------------
describe('Security — secret never leaks', () => {
it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => {
const app = makeApp();
const res = await postSonarr(app, {
eventType: 'Grab',
instanceName: 'Main Sonarr',
date: '2026-05-19T18:00:00.000Z'
});
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
});
it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => {
const app = makeApp();
const res = await postRadarr(app, RADARR_GRAB, 'wrong');
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { vi, beforeEach, afterEach } from 'vitest'; import { vi, beforeEach, afterEach } from 'vitest';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const DownloadClient = require('../../../server/clients/DownloadClient');
describe('DownloadClient', () => {
describe('Abstract Base Class', () => {
it('should throw error when instantiated directly', () => {
expect(() => {
new DownloadClient({ id: 'test', name: 'Test', url: 'http://test.com' });
}).toThrow('DownloadClient is an abstract class and cannot be instantiated directly');
});
it('should enforce implementation of required methods', () => {
class TestClient extends DownloadClient {
getClientType() {
return 'test';
}
}
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
expect(() => client.testConnection()).rejects.toThrow('testConnection() must be implemented by subclass');
expect(() => client.getActiveDownloads()).rejects.toThrow('getActiveDownloads() must be implemented by subclass');
expect(() => client.normalizeDownload({})).toThrow('normalizeDownload() must be implemented by subclass');
});
});
describe('Base Properties', () => {
class TestClient extends DownloadClient {
getClientType() {
return 'test';
}
async testConnection() {
return true;
}
async getActiveDownloads() {
return [];
}
normalizeDownload(download) {
return download;
}
}
it('should set basic properties from config', () => {
const config = {
id: 'test-instance',
name: 'Test Instance',
url: 'http://test.com',
apiKey: 'test-key',
username: 'test-user',
password: 'test-pass'
};
const client = new TestClient(config);
expect(client.id).toBe('test-instance');
expect(client.name).toBe('Test Instance');
expect(client.url).toBe('http://test.com');
expect(client.apiKey).toBe('test-key');
expect(client.username).toBe('test-user');
expect(client.password).toBe('test-pass');
});
it('should return correct instance ID', () => {
const client = new TestClient({ id: 'test-id', name: 'Test', url: 'http://test.com' });
expect(client.getInstanceId()).toBe('test-id');
});
it('should have optional getClientStatus method returning null', async () => {
const client = new TestClient({ id: 'test', name: 'Test', url: 'http://test.com' });
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
@@ -0,0 +1,208 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import QBittorrentClient from '../../../server/clients/QBittorrentClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('QBittorrentClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-qb',
name: 'Test qBittorrent',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
};
client = new QBittorrentClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('qbittorrent');
expect(client.getInstanceId()).toBe('test-qb');
expect(client.name).toBe('Test qBittorrent');
expect(client.url).toBe('http://localhost:8080');
expect(client.authCookie).toBeNull();
expect(client.lastRid).toBe(0);
expect(client.torrentMap).toBeInstanceOf(Map);
expect(client.fallbackThisCycle).toBe(false);
});
});
describe('Authentication', () => {
it('should login successfully with valid credentials', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, { 'set-cookie': ['SID=test-cookie'] });
const result = await client.login();
expect(result).toBe(true);
expect(client.authCookie).toBe('SID=test-cookie');
});
it('should handle login failure', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, {});
const result = await client.login();
expect(result).toBe(false);
expect(client.authCookie).toBeNull();
});
it('should handle login error', async () => {
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.replyWithError(new Error('Network error'));
const result = await client.login();
expect(result).toBe(false);
expect(client.authCookie).toBeNull();
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
// Mock login success
client.login = vi.fn().mockResolvedValue(true);
// Mock version request
const mockResponse = { data: 'v4.3.5' };
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('/api/v2/app/version');
});
it('should handle connection test failure', async () => {
client.login = vi.fn().mockRejectedValue(new Error('Auth failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('Download Normalization', () => {
it('should normalize torrent data correctly', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'downloading',
progress: 0.75,
size: 1000000000,
completed: 750000000,
dlspeed: 1048576,
eta: 3600,
category: 'movies',
tags: 'movie,hd',
content_path: '/downloads/test',
added_on: 1640995200
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'qbittorrent',
instanceId: 'test-qb',
instanceName: 'Test qBittorrent',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: ['movie', 'hd'],
savePath: '/downloads/test',
addedOn: '2022-01-01T00:00:00.000Z',
raw: torrent
});
});
it('should handle unknown torrent states', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'unknown_state',
progress: 0.5,
size: 1000000,
completed: 500000,
dlspeed: 0,
eta: -1
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('unknown_state');
expect(normalized.eta).toBeNull();
});
it('should handle missing completed field', () => {
const torrent = {
hash: 'abc123',
name: 'Test Torrent',
state: 'downloading',
progress: 0.5,
size: 1000000,
dlspeed: 0
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.downloaded).toBe(500000);
});
});
describe('Fallback Flag Management', () => {
it('should reset fallback flag', () => {
client.fallbackThisCycle = true;
client.resetFallbackFlag();
expect(client.fallbackThisCycle).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle makeRequest authentication failure', async () => {
client.authCookie = 'invalid-cookie';
// First request fails with 403
nock('http://localhost:8080')
.get('/test')
.reply(403, {});
// Re-authentication succeeds
nock('http://localhost:8080')
.post('/api/v2/auth/login', 'username=admin&password=adminadmin')
.reply(200, {}, { 'set-cookie': ['SID=new-cookie'] });
// Retry succeeds
nock('http://localhost:8080')
.get('/test')
.reply(200, { data: 'success' });
const result = await client.makeRequest('/test');
expect(result.data).toEqual({ data: 'success' });
expect(client.authCookie).toBe('SID=new-cookie');
});
});
});
+423
View File
@@ -0,0 +1,423 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import RTorrentClient from '../../../server/clients/RTorrentClient.js';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('RTorrentClient', () => {
let client;
let mockConfig;
let mockMethodCall;
beforeEach(() => {
mockMethodCall = vi.fn();
mockConfig = {
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080',
username: 'rtorrent',
password: 'rtorrent'
};
client = new RTorrentClient(mockConfig);
// Mock the xmlrpc client's methodCall directly
client.client.methodCall = mockMethodCall;
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('rtorrent');
expect(client.getInstanceId()).toBe('test-rtorrent');
expect(client.name).toBe('Test rTorrent');
expect(client.url).toBe('http://localhost:8080');
});
it('should create xmlrpc client with correct URL', async () => {
expect(client.url).toBe('http://localhost:8080');
expect(client.client).toBeDefined();
});
it('should create xmlrpc client without auth when no credentials', () => {
const noAuthConfig = {
id: 'test-rtorrent-noauth',
name: 'Test rTorrent No Auth',
url: 'http://localhost:8080/RPC2'
};
const clientNoAuth = new RTorrentClient(noAuthConfig);
expect(clientNoAuth.client).toBeDefined();
});
it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => {
const whatboxConfig = {
id: 'test-whatbox',
name: 'Whatbox',
url: 'https://user.whatbox.ca/xmlrpc',
username: 'user',
password: 'pass'
};
const clientWhatbox = new RTorrentClient(whatboxConfig);
expect(clientWhatbox.client).toBeDefined();
});
it('should use custom RPC path exactly as configured', () => {
const customConfig = {
id: 'test-custom',
name: 'Custom',
url: 'https://example.com/custom/rpc/path'
};
const clientCustom = new RTorrentClient(customConfig);
expect(clientCustom.client).toBeDefined();
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, '0.9.8');
});
const result = await client.testConnection();
expect(result).toBe(true);
});
it('should handle connection test failure', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Connection refused'));
});
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('getActiveDownloads', () => {
it('should fetch and normalize torrents', async () => {
const mockTorrents = [
[
'abc123def456',
'Test Torrent 1',
1000000000,
750000000,
1048576,
0,
1,
1,
0,
'/downloads/test',
'movies'
],
[
'def789abc012',
'Test Torrent 2',
2000000000,
2000000000,
0,
512000,
1,
1,
0,
'/downloads/complete',
'tv'
]
];
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, mockTorrents);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('abc123def456');
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[0].progress).toBe(75);
expect(downloads[0].category).toBe('movies');
expect(downloads[1].status).toBe('Seeding');
expect(downloads[1].category).toBe('tv');
});
it('should handle empty torrent list', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, []);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle XML-RPC errors gracefully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('XML-RPC fault'));
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('normalizeDownload', () => {
it('should normalize a downloading torrent', () => {
const torrent = [
'hash123',
'Downloading Torrent',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'hash123',
title: 'Downloading Torrent',
type: 'torrent',
client: 'rtorrent',
instanceId: 'test-rtorrent',
instanceName: 'Test rTorrent',
status: 'Downloading',
progress: 50,
size: 1000000000,
downloaded: 500000000,
speed: 1048576,
eta: 477,
category: undefined,
tags: [],
savePath: '/downloads',
addedOn: undefined,
arrQueueId: undefined,
arrType: undefined,
raw: torrent
});
});
it('should normalize a seeding torrent', () => {
const torrent = [
'hash456',
'Seeding Torrent',
500000000,
500000000,
0,
204800,
1,
1,
0,
'/downloads/complete',
'movies'
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Seeding');
expect(normalized.progress).toBe(100);
expect(normalized.speed).toBe(204800);
expect(normalized.eta).toBeNull();
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies']);
});
it('should normalize a paused torrent', () => {
const torrent = [
'hash789',
'Paused Torrent',
1000000000,
250000000,
0,
0,
1,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Paused');
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
});
it('should normalize a stopped torrent', () => {
const torrent = [
'hashabc',
'Stopped Torrent',
1000000000,
0,
0,
0,
0,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Stopped');
});
it('should normalize a checking torrent', () => {
const torrent = [
'hashdef',
'Checking Torrent',
1000000000,
500000000,
0,
0,
1,
0,
1,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Checking');
});
it('should handle zero-size torrent', () => {
const torrent = [
'hash000',
'Zero Size',
0,
0,
0,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.progress).toBe(0);
expect(normalized.size).toBe(0);
expect(normalized.downloaded).toBe(0);
});
});
describe('Status Mapping', () => {
const testCases = [
{ state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' },
{ state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }
];
testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => {
it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => {
const status = client._mapStatus(state, isActive, isHashChecking, completed, size);
expect(status).toBe(expected);
});
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = [
'hash123',
'Show Name - S01E02 - Episode Title',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = [
'hash456',
'Movie Title (2023) 1080p',
2000000000,
1000000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = [
'hash789',
'Generic File Name.mkv',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
describe('Client Status', () => {
it('should get client status', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
if (method === 'throttle.global_down.rate') {
callback(null, 1048576);
} else if (method === 'throttle.global_up.rate') {
callback(null, 512000);
}
});
const status = await client.getClientStatus();
expect(status).toEqual({
globalDownRate: 1048576,
globalUpRate: 512000
});
});
it('should handle status request errors', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Status error'));
});
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
+302
View File
@@ -0,0 +1,302 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import SABnzbdClient from '../../../server/clients/SABnzbdClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('SABnzbdClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
};
client = new SABnzbdClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('sabnzbd');
expect(client.getInstanceId()).toBe('test-sab');
expect(client.name).toBe('Test SABnzbd');
expect(client.url).toBe('http://localhost:8080');
expect(client.apiKey).toBe('test-api-key');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { version: '3.6.1' }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('', { mode: 'version' });
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('API Requests', () => {
it('should make API request with correct parameters', async () => {
nock('http://localhost:8080')
.get('/api')
.query({
output: 'json',
apikey: 'test-api-key',
mode: 'queue',
limit: 10
})
.reply(200, { result: 'success' });
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
expect(result.data).toEqual({ result: 'success' });
});
it('should handle API request errors', async () => {
nock('http://localhost:8080')
.get('/api')
.query({ output: 'json', apikey: 'test-api-key', mode: 'queue' })
.replyWithError(new Error('API Error'));
await expect(client.makeRequest({ mode: 'queue' })).rejects.toThrow('API Error');
});
});
describe('Download Normalization', () => {
it('should normalize queue download correctly', () => {
const slot = {
nzo_id: 'test123',
filename: 'Test Movie.mkv',
status: 'Downloading',
progress: 75.5,
mb: 1000,
mbleft: 245,
kbpersec: 1024,
timeleft: '0:15:30',
cat: 'movies',
labels: 'movie,hd',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized).toEqual({
id: 'test123',
title: 'Test Movie.mkv',
type: 'usenet',
client: 'sabnzbd',
instanceId: 'test-sab',
instanceName: 'Test SABnzbd',
status: 'Downloading',
progress: 76,
size: 1000 * 1024 * 1024,
downloaded: 755 * 1024 * 1024,
speed: 1024 * 1024,
eta: 930, // 15 minutes 30 seconds
category: 'movies',
tags: ['movie', 'hd'],
savePath: undefined,
addedOn: '2022-01-01T00:00:00.000Z',
raw: { ...slot, source: 'queue' }
});
});
it('should normalize history download correctly', () => {
const slot = {
nzo_id: 'test456',
filename: 'Test Series S01E01.mkv',
status: 'Completed',
mb: 500,
mbleft: 0,
cat: 'tv',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'history');
expect(normalized.status).toBe('Completed');
expect(normalized.progress).toBe(100);
expect(normalized.downloaded).toBe(500 * 1024 * 1024);
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
expect(normalized.raw.source).toBe('history');
});
it('should parse time strings correctly', () => {
const testCases = [
{ input: '0:05:30', expected: 330 }, // 5m 30s
{ input: '15:30', expected: 930 }, // 15m 30s
{ input: '330', expected: 330 }, // 330 seconds
{ input: 'unknown', expected: null }, // unknown
{ input: '0:00', expected: null } // zero
];
testCases.forEach(({ input, expected }) => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500,
timeleft: input
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.eta).toBe(expected);
});
});
it('should extract Sonarr/Radarr info from filename', () => {
const testCases = [
{ filename: 'Show Name - S01E02 - Episode Title', expectedType: 'series' },
{ filename: 'Movie Title (2023) 1080p', expectedType: 'movie' },
{ filename: 'Random File Name.mkv', expectedType: undefined }
];
testCases.forEach(({ filename, expectedType }) => {
const slot = {
nzo_id: 'test',
filename: filename,
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.arrType).toBe(expectedType);
});
});
it('should handle size parsing from strings', () => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
size: '1.5 GB',
sizeleft: '0.75 GB'
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.size).toBe(1.5 * 1024 * 1024 * 1024);
expect(normalized.downloaded).toBe(0.75 * 1024 * 1024 * 1024);
expect(normalized.progress).toBe(50);
});
});
describe('Unit Multipliers', () => {
it('should return correct multipliers for different units', () => {
expect(client.getUnitMultiplier('b')).toBe(1);
expect(client.getUnitMultiplier('KB')).toBe(1024);
expect(client.getUnitMultiplier('mb')).toBe(1024 * 1024);
expect(client.getUnitMultiplier('GB')).toBe(1024 * 1024 * 1024);
expect(client.getUnitMultiplier('tb')).toBe(1024 * 1024 * 1024 * 1024);
expect(client.getUnitMultiplier('unknown')).toBe(1);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize downloads from queue and history', async () => {
const mockQueueResponse = {
data: {
queue: {
slots: [
{ nzo_id: 'queue1', filename: 'Queue Item', status: 'Downloading' }
]
}
}
};
const mockHistoryResponse = {
data: {
history: {
slots: [
{ nzo_id: 'hist1', filename: 'History Item', status: 'Completed' }
]
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockQueueResponse)
.mockResolvedValueOnce(mockHistoryResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'queue' });
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 10 });
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('queue1');
expect(downloads[1].id).toBe('hist1');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status', async () => {
const mockResponse = {
data: {
queue: {
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const status = await client.getClientStatus();
expect(status).toEqual({
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
@@ -0,0 +1,436 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import TransmissionClient from '../../../server/clients/TransmissionClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('TransmissionClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-transmission',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
};
client = new TransmissionClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('transmission');
expect(client.getInstanceId()).toBe('test-transmission');
expect(client.name).toBe('Test Transmission');
expect(client.url).toBe('http://localhost:9091');
expect(client.sessionId).toBeNull();
expect(client.rpcUrl).toBe('http://localhost:9091/transmission/rpc');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { result: 'success', arguments: {} }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('RPC Requests', () => {
it('should make RPC request with session ID', async () => {
client.sessionId = 'test-session-id';
nock('http://localhost:9091')
.post('/transmission/rpc', {
method: 'torrent-get',
arguments: { fields: ['id', 'name'] }
})
.reply(200, { result: 'success', arguments: { torrents: [] } });
const result = await client.makeRequest('torrent-get', { fields: ['id', 'name'] });
expect(result.data).toEqual({ result: 'success', arguments: { torrents: [] } });
});
it('should handle session ID conflict (409)', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(409, {}, { 'x-transmission-session-id': 'new-session-id' });
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(200, { result: 'success', arguments: {} });
const result = await client.makeRequest('session-get');
expect(client.sessionId).toBe('new-session-id');
expect(result.data).toEqual({ result: 'success', arguments: {} });
});
it('should handle RPC errors', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'invalid-method', arguments: {} })
.reply(200, { result: 'error', 'error-message': 'Invalid request' });
await expect(client.makeRequest('invalid-method')).rejects.toThrow('Transmission RPC error: error');
});
});
describe('Download Normalization', () => {
it('should normalize torrent data correctly', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test Torrent',
status: 4, // downloading
totalSize: 1000000000,
sizeWhenDone: 1000000000,
leftUntilDone: 250000000,
rateDownload: 1048576,
rateUpload: 0,
eta: 3600,
downloadedEver: 750000000,
uploadedEver: 0,
percentDone: 0.75,
addedDate: 1640995200,
doneDate: 0,
labels: ['movies', 'hd'],
downloadDir: '/downloads/test'
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'transmission',
instanceId: 'test-transmission',
instanceName: 'Test Transmission',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: ['movies', 'hd'],
savePath: '/downloads/test',
addedOn: '2022-01-01T00:00:00.000Z',
raw: torrent
});
});
it('should handle different torrent statuses', () => {
const statusMap = {
0: 'Stopped',
1: 'Queued',
2: 'Checking',
3: 'Queued',
4: 'Downloading',
5: 'Queued',
6: 'Seeding',
7: 'Unknown'
};
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: parseInt(status),
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe(expectedStatus);
});
});
it('should handle unknown ETA values', () => {
const testCases = [
{ eta: -1, expected: null },
{ eta: -2, expected: null },
{ eta: 3600, expected: 3600 }
];
testCases.forEach(({ eta, expected }) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: eta,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.eta).toBe(expected);
});
});
it('should extract category from first label', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: ['movies', 'hd', '4k']
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies', 'hd', '4k']);
});
it('should handle empty labels', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBeUndefined();
expect(normalized.tags).toEqual([]);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize torrents', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: {
torrents: [
{
id: 1,
hashString: 'abc123',
name: 'Test Torrent 1',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 1048576,
eta: 3600,
percentDone: 0.5,
labels: ['test']
},
{
id: 2,
hashString: 'def456',
name: 'Test Torrent 2',
status: 6,
totalSize: 2000000,
sizeWhenDone: 2000000,
leftUntilDone: 0,
rateDownload: 0,
eta: -1,
percentDone: 1.0,
labels: []
}
]
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
expect(downloads).toHaveLength(2);
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[1].title).toBe('Test Torrent 2');
expect(downloads[1].status).toBe('Seeding');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle empty torrent list', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: { torrents: [] }
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status and session stats', async () => {
const mockSessionResponse = {
data: {
result: 'success',
arguments: {
'download-dir': '/downloads',
'peer-port': 51413,
'rpc-version': 15
}
}
};
const mockStatsResponse = {
data: {
result: 'success',
arguments: {
'downloaded-bytes': 1000000000,
'uploaded-bytes': 500000000,
'torrent-count': 5
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockSessionResponse)
.mockResolvedValueOnce(mockStatsResponse);
const status = await client.getClientStatus();
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
expect(client.makeRequest).toHaveBeenCalledWith('session-stats');
expect(status).toEqual({
session: mockSessionResponse.data.arguments,
stats: mockStatsResponse.data.arguments
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Show Name - S01E02 - Episode Title',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Movie Title (2023) 1080p',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Generic File Name.mkv',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/utils/config.js * Tests for server/utils/config.js
* *
+347
View File
@@ -0,0 +1,347 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
DownloadClientRegistry,
registry,
initializeClients,
getAllClients,
getClient,
getClientsByType,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
getAllClientStatuses
} from '../../server/utils/downloadClients.js';
import * as mockConfig from '../../server/utils/config.js';
import { vi } from 'vitest';
// Mock config and clients
vi.mock('../../server/utils/config', () => ({
getSABnzbdInstances: vi.fn(),
getQbittorrentInstances: vi.fn(),
getTransmissionInstances: vi.fn(),
getRtorrentInstances: vi.fn()
}));
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
vi.mock('../../server/clients/SABnzbdClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'sabnzbd',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/QBittorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'qbittorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' }),
resetFallbackFlag: vi.fn()
}));
});
vi.mock('../../server/clients/TransmissionClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'transmission',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'trans1', title: 'Trans Download 1', client: 'transmission' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/RTorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'rtorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'rt1', title: 'rTorrent Download 1', client: 'rtorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
describe('DownloadClientRegistry', () => {
let testRegistry;
beforeEach(() => {
testRegistry = new DownloadClientRegistry();
vi.clearAllMocks();
});
describe('Initialization', () => {
it('should initialize all configured client types', async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1'
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1'
};
const mockTransClient = {
getClientType: () => 'transmission',
getInstanceId: () => 'trans1',
name: 'Trans 1'
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
testRegistry.clients.set('trans1', mockTransClient);
expect(testRegistry.getAllClients()).toHaveLength(3);
expect(testRegistry.getClient('sab1')).toBeTruthy();
expect(testRegistry.getClient('qb1')).toBeTruthy();
expect(testRegistry.getClient('trans1')).toBeTruthy();
});
it('should handle empty config', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
it('should not initialize twice', async () => {
// Manually set initialized flag to true
testRegistry.initialized = true;
// Try to initialize again
await testRegistry.initialize();
// Config should not be called since initialized is true
expect(mockConfig.getSABnzbdInstances).not.toHaveBeenCalled();
});
it('should handle client creation errors gracefully', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
});
describe('Client Management', () => {
beforeEach(async () => {
// Manually add mock client to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
});
it('should get all clients', () => {
const clients = testRegistry.getAllClients();
expect(clients).toHaveLength(1);
expect(clients[0].getClientType()).toBe('sabnzbd');
});
it('should get client by ID', () => {
const client = testRegistry.getClient('sab1');
expect(client).toBeTruthy();
expect(client.getInstanceId()).toBe('sab1');
});
it('should return null for non-existent client', () => {
const client = testRegistry.getClient('nonexistent');
expect(client).toBeNull();
});
it('should get clients by type', () => {
const sabClients = testRegistry.getClientsByType('sabnzbd');
expect(sabClients).toHaveLength(1);
const qbClients = testRegistry.getClientsByType('qbittorrent');
expect(qbClients).toHaveLength(0);
});
});
describe('Download Management', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
resetFallbackFlag: vi.fn()
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should get all downloads from all clients', async () => {
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].client).toBe('sabnzbd');
expect(downloads[1].client).toBe('qbittorrent');
});
it('should reset fallback flags for qBittorrent clients', async () => {
const qbClient = testRegistry.getClient('qb1');
await testRegistry.getAllDownloads();
expect(qbClient.resetFallbackFlag).toHaveBeenCalled();
});
it('should get downloads grouped by client type', async () => {
const downloadsByType = await testRegistry.getDownloadsByClientType();
expect(downloadsByType.sabnzbd).toHaveLength(1);
expect(downloadsByType.qbittorrent).toHaveLength(1);
expect(downloadsByType.transmission).toBeUndefined();
});
it('should handle client errors gracefully', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getActiveDownloads.mockRejectedValue(new Error('Client error'));
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(1); // Only qBittorrent succeeds
});
});
describe('Connection Testing', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should test all connections', async () => {
const results = await testRegistry.testAllConnections();
expect(results).toHaveLength(2);
expect(results[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
success: true,
error: null
});
expect(results[1]).toEqual({
instanceId: 'qb1',
instanceName: 'QB 1',
clientType: 'qbittorrent',
success: true,
error: null
});
});
it('should handle connection test failures', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.testConnection.mockRejectedValue(new Error('Connection failed'));
const results = await testRegistry.testAllConnections();
expect(results[0].success).toBe(false);
expect(results[0].error).toBe('Connection failed');
});
});
describe('Client Status', () => {
beforeEach(async () => {
// Manually add a mock client to the registry
const mockClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
};
testRegistry.clients.set('sab1', mockClient);
});
it('should get all client statuses', async () => {
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
status: { status: 'active' }
});
});
it('should handle status request errors', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getClientStatus.mockRejectedValue(new Error('Status error'));
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses[0].status).toBeNull();
expect(statuses[0].error).toBe('Status error');
});
});
});
describe('Convenience Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should delegate to singleton registry', async () => {
mockConfig.getSABnzbdInstances.mockReturnValue([]);
mockConfig.getQbittorrentInstances.mockReturnValue([]);
mockConfig.getTransmissionInstances.mockReturnValue([]);
await initializeClients();
expect(getAllClients()).toBeInstanceOf(Array);
expect(getClient('test')).toBeNull();
expect(getClientsByType('sabnzbd')).toBeInstanceOf(Array);
expect(await getAllDownloads()).toBeInstanceOf(Array);
expect(await getDownloadsByClientType()).toBeInstanceOf(Object);
expect(await testAllConnections()).toBeInstanceOf(Array);
expect(await getAllClientStatuses()).toBeInstanceOf(Array);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Unit tests for server/utils/historyFetcher.js * Unit tests for server/utils/historyFetcher.js
* *
+259
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/utils/qbittorrent.js pure utility functions. * Tests for server/utils/qbittorrent.js pure utility functions.
* *
@@ -7,6 +8,8 @@
*/ */
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js'; import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
import QBittorrentClient from '../../server/clients/QBittorrentClient.js';
import nock from 'nock';
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations // Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
function makeTorrent(overrides = {}) { function makeTorrent(overrides = {}) {
@@ -31,6 +34,35 @@ function makeTorrent(overrides = {}) {
}; };
} }
const QBT_URL = 'http://qbittorrent.test:8080';
function makeClient(overrides = {}) {
return new QBittorrentClient({
id: 'test-qbt',
name: 'TestQBT',
url: QBT_URL,
username: 'admin',
password: 'adminadmin',
...overrides
});
}
function mockLogin() {
return nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=abc123; path=/'] });
}
function mockSync(rid, response) {
return nock(QBT_URL)
.get(`/api/v2/sync/maindata?rid=${rid}`)
.reply(200, response);
}
afterEach(() => {
nock.cleanAll();
});
describe('formatBytes', () => { describe('formatBytes', () => {
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B')); it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B')); it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
@@ -109,3 +141,230 @@ describe('mapTorrentToDownload', () => {
expect(result.savePath).toBe('/dl/'); expect(result.savePath).toBe('/dl/');
}); });
}); });
describe('QBittorrentClient sync API', () => {
it('first call uses rid=0 and returns full torrent list', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test1');
expect(torrents[0].instanceId).toBe('test-qbt');
expect(torrents[0].id).toBe('hash01');
expect(client.lastRid).toBe(1);
});
it('subsequent call uses last rid and merges delta', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Second call — delta
mockSync(1, {
rid: 2,
full_update: false,
torrents: {
hash01: { dlspeed: 200 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].speed).toBe(200);
expect(torrents[0].title).toBe('Test1');
expect(client.lastRid).toBe(2);
});
it('handles full_update=true on subsequent call', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Server forces full refresh
mockSync(1, {
rid: 2,
full_update: true,
torrents: {
hash02: { name: 'Test2', state: 'uploading', size: 2000, progress: 1.0, dlspeed: 0, eta: 0, num_seeds: 10, num_leechs: 0, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test2');
expect(torrents[0].id).toBe('hash02');
expect(client.lastRid).toBe(2);
});
it('removes torrents when torrents_removed is present', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
mockSync(1, {
rid: 2,
full_update: false,
torrents_removed: ['hash01']
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(0);
});
it('falls back to torrents/info when sync fails', async () => {
mockLogin();
const client = makeClient();
await client.login();
// Sync fails with 500
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(500, { error: 'Internal Server Error' });
// Legacy succeeds
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'Fallback', hash: 'fb01', state: 'downloading', size: 1073741824, progress: 0.5, dlspeed: 1048576, eta: 512, num_seeds: 10, num_leechs: 3, availability: 1.0 }
]);
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Fallback');
expect(client.fallbackThisCycle).toBe(true);
});
it('uses legacy immediately if already fell back this cycle', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// Only legacy should be called
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'DirectLegacy', hash: 'dl01', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
]);
// Ensure sync is NOT called
const syncScope = nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, { rid: 1, full_update: true });
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('DirectLegacy');
expect(syncScope.isDone()).toBe(false);
});
it('re-authenticates on 403 during sync and retries', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First sync call returns 403
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(403, {});
// Re-login
nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=newtoken; path=/'] });
// Retry succeeds
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'AfterReauth', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('AfterReauth');
});
it('computes completed from size and progress when missing', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'NoCompleted', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents[0].downloaded).toBe(500);
});
it('resets fallback flag when getAllTorrents resets it', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// After reset, sync should be attempted
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'ResetWorks', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
// Simulate the reset that getAllTorrents performs
client.fallbackThisCycle = false;
const torrents = await client.getActiveDownloads();
expect(torrents[0].title).toBe('ResetWorks');
expect(client.fallbackThisCycle).toBe(false);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/middleware/requireAuth.js * Tests for server/middleware/requireAuth.js
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/utils/sanitizeError.js * Tests for server/utils/sanitizeError.js
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/utils/tokenStore.js * Tests for server/utils/tokenStore.js
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/** /**
* Tests for server/middleware/verifyCsrf.js * Tests for server/middleware/verifyCsrf.js
* *
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({