Compare commits

..

190 Commits

Author SHA1 Message Date
gronod 9aca9c45e2 Release v1.6.0
Create Release / release (push) Successful in 32s
Build and Push Docker Image / build (push) Successful in 34s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m38s
Major feature release bringing technical-debt remediation, service extraction, frontend migration, and staged history loading.

### Added
- Staged history loading with SSE push — history data from Sonarr/Radarr is now fetched in stages; history-update SSE events push incremental results to the dashboard
- Frontend unit tests — added Vitest + jsdom test suite covering client/src/ modules
- Comprehensive tests for staged history loading — backend tests verify background-fetch behaviour, cache TTL handling, and SSE emission
- Integration test coverage — new integration and unit tests for dashboard, emby, sonarr, radarr, and sabnzbd routes

### Changed
- Technical-debt remediation — service extraction — extracted matching and assembly logic from monolithic dashboard.js (~1,360 lines → ~284 lines) into dedicated services: DownloadMatcher.js, DownloadAssembler.js, DownloadBuilder.js, TagMatcher.js, WebhookStatus.js
- Frontend architecture — migrated from monolithic public/app.js to vanilla ES modules under client/src/, bundled by Vite
- History pagination — replaced custom date-based cursor pagination with retriever's built-in pagination (pageSize=100, up to 10 pages / 1,000 records total)
- Status endpoint path — admin status route moved from /api/status/status to /api/status
- Background-fetch safety — poller no longer overwrites cache with empty data when background fetch fails
- SABnzbd progress calculation — progress computed from slot.mb and slot.mbleft / mbmissing
- Speed formatting consistency — updateDownloadCard() now calls formatSpeed() for uniform units
- Status-panel error handling — panel now surfaces error messages instead of blank box

### Fixed
- CSRF token reference errors — fixed ReferenceError bugs in api.js, auth.js, and blocklist handler
- Logout button — fixed undefined variable references
- Missing progress bar for SABnzbd — SABnzbd downloads now render correct progress bar
- Status route 404 — corrected Express router mount
- Status button DOM ID — fixed element-ID mismatch
- Tab selection — fixed to use data-tab attributes
- CSP violations and ignoreAvailable reference error
- Docker client-build stage — removed client/ from .dockerignore
- Unmatched torrent exclusion — torrents not matching Sonarr/Radarr record now correctly omitted
- Blocklist button CSRF — fixed ReferenceError

### Breaking Changes
- Unmatched torrents are no longer displayed — torrents that do not match a Sonarr/Radarr queue or history record are excluded
- Frontend build process changed — monolithic public/app.js source replaced by Vite build from client/src/
- Status API endpoint path changed — admin status endpoint moved from /api/status/status to /api/status
2026-05-21 11:55:55 +01:00
gronod 5d0da45e10 chore: bump version to 1.6.0, update CHANGELOG and ARCHITECTURE docs
Build and Push Docker Image / build (push) Successful in 48s
CI / Security audit (push) Successful in 1m23s
Docs Check / Markdown lint (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m18s
Docs Check / Mermaid diagram parse check (push) Successful in 1m44s
2026-05-21 11:49:57 +01:00
Gandalf adbb0c12c1 Merge pull request 'fix: remove client/ from .dockerignore to fix Docker client-build stage' (#27) from develop-refactor into develop-refactor2
Build and Push Docker Image / build (push) Successful in 43s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m33s
Reviewed-on: #27
2026-05-21 09:28:17 +01:00
gronod 9a4408e797 Fix CSRF token and currentUser references in api.js
Build and Push Docker Image / build (push) Successful in 52s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m17s
CI / Security audit (push) Successful in 1m31s
CI / Tests & coverage (push) Successful in 1m47s
- Use state.csrfToken instead of undefined csrfToken in handleLogout
- Use state.currentUser instead of undefined currentUser in handleLogout
- Use state.csrfToken instead of undefined csrfToken in enableSonarrWebhook
- Fixes ReferenceError bugs in logout and webhook functions
2026-05-21 02:17:48 +01:00
gronod 05cf5a0993 Fix blocklist CSRF token reference error
Build and Push Docker Image / build (push) Successful in 56s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
- Use state.csrfToken instead of undefined csrfToken variable
- Fixes ReferenceError when clicking blocklist button
2026-05-21 02:16:34 +01:00
gronod bb10cd4aef Add client-side logging to blocklist button for debugging
Build and Push Docker Image / build (push) Successful in 53s
CI / Tests & coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m23s
CI / Security audit (push) Has been cancelled
- Log download object and required fields when button is clicked
- Shows which fields are present/absent to diagnose blocklist issues
2026-05-21 02:15:09 +01:00
gronod 251c10f08c Add logging to blocklist-search endpoint for debugging
CI / Tests & coverage (push) Failing after 31s
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m10s
CI / Security audit (push) Successful in 1m28s
- Log missing required fields to help diagnose blocklist issues
- Shows which fields are present/absent in the request body
2026-05-21 02:11:45 +01:00
gronod 474ae949a9 Fix progress calculation to support both mbleft and mbmissing fields
Build and Push Docker Image / build (push) Successful in 35s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m13s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m44s
- SABnzbd API uses different field names for remaining bytes
- Test data uses mbmissing, but real API may use mbleft
- Support both field names to handle all cases
- Fixes DownloadBuilder test failures
2026-05-21 02:07:54 +01:00
gronod 084cb0579e Fix missing progress bar for SABnzbd downloads
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m5s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Failing after 1m39s
- Calculate progress from slot.mb and slot.mbleft instead of slot.percentage
- Apply fix to both series and movie downloads in matchSabSlots
- Add progress: 100 for history items in matchSabHistory (series and movie)
- SABnzbd slot data doesn't have percentage field, so progress was undefined
2026-05-21 01:55:03 +01:00
gronod 93a09e10a8 Fix background fetch to not overwrite cache with empty data
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m18s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Successful in 1m15s
- Only trigger background refresh if cache is incomplete (less than max records)
- Only update cache if background fetch returns records (don't overwrite on failure)
- Prevents test failures when background fetch fails to connect to Sonarr/Radarr
- Fixes integration test 'includes imported and failed records, excludes grabbed'
2026-05-21 01:53:19 +01:00
gronod 47817d057b Fix status route test paths
Build and Push Docker Image / build (push) Successful in 1m3s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m17s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Failing after 1m41s
- Update test paths from /api/status/status to /api/status
- Matches the route change from /status to / in status.js
2026-05-21 01:51:00 +01:00
gronod f6ad7c85bf Fix inconsistent speed formatting in download updates
Build and Push Docker Image / build (push) Successful in 55s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m7s
CI / Security audit (push) Successful in 1m32s
CI / Tests & coverage (push) Failing after 1m51s
- Use formatSpeed() when updating existing download card speed
- Ensures consistent unit display (B/s, KB/s, MB/s, GB/s) across all updates
- Previously used raw speed value causing inconsistent display
2026-05-21 01:48:00 +01:00
gronod a349c8e2cf Fix status route path to avoid 404
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 52s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m17s
- Change route from '/status' to '/' since router is mounted at '/api/status'
- Fixes HTML 404 response when client calls /api/status
2026-05-21 01:41:24 +01:00
gronod f2b44f65af Fix logout button by using state object references
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m5s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Failing after 1m32s
- Fix undefined variable references in handleLogoutClick
- Use state.statusRefreshHandle instead of statusRefreshHandle
- Use state.currentUser instead of currentUser
2026-05-21 01:39:00 +01:00
gronod b3664747cb Add error display for status panel failures
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Has been cancelled
- Show error message when status API returns error (e.g., 403 for non-admin)
- Helps debug why status panel appears empty
2026-05-21 01:37:31 +01:00
gronod 14b47ce410 Fix history pagination and status panel issues
Build and Push Docker Image / build (push) Successful in 52s
Docs Check / Markdown lint (push) Successful in 1m1s
CI / Tests & coverage (push) Failing after 1m32s
CI / Security audit (push) Successful in 1m33s
Docs Check / Mermaid diagram parse check (push) Successful in 1m38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
- Fix history pagination: use retriever's built-in maxPages parameter instead of broken date-based cursor
- Fix status panel: correct API endpoint from /api/dashboard/status to /api/status
- Background fetch now properly fetches up to 1000 records (10 pages * 100 records)
- Status panel will now display details instead of 'Loading Status...'
2026-05-21 01:34:54 +01:00
gronod 5ec5484b91 docs(ARCHITECTURE): update to reflect develop-refactor2 changes
- Add Download Matching & Assembly Services section (3.3)
  covering DownloadBuilder, DownloadMatcher, DownloadAssembler,
  TagMatcher, and WebhookStatus
- Update High-Level Architecture diagram with /api/status route
- Update Data Flow pipeline to show service-based matching,
  stable downloadId priority, deduplication, unmatched-torrent exclusion
- Update Key Subsystems: Vite+ES module frontend, client/src tree,
  CSP compliance, PDCA client fix callouts
- Update Directory Structure with services/, client/src/, tests/frontend/
- Update Technology Stack with jsdom and Vite build notes
- Update webhook replay protection and PALDRA pagination details
2026-05-21 01:32:36 +01:00
gronod 05c9527189 Add comprehensive tests for staged history loading
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m25s
Unit tests:
- Staged loading (initial batch, background fetch, concurrent requests)
- Deduplication (by record ID, empty record sets)
- Event subscription (subscribe/unsubscribe, error handling)
- Pagination (max records limit, batch sizes)

Integration tests:
- Race conditions (concurrent requests, cache consistency, duplicate handling)
- Edge cases (empty history, single record, batch boundaries)

Tests verify:
- No records are missed during staged loading
- No duplicates are created
- Cache remains consistent during concurrent operations
- Background fetch doesn't interfere with concurrent user requests
2026-05-21 01:28:37 +01:00
gronod f461c3669c Add client-side handler for history-update SSE events
CI / Security audit (push) Failing after 4s
Build and Push Docker Image / build (push) Successful in 19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 30s
CI / Tests & coverage (push) Successful in 45s
2026-05-21 01:23:30 +01:00
gronod 0acd452ebd Implement staged history loading with SSE push
Build and Push Docker Image / build (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Stage 1: Fetch 100 records immediately for fast display
- Stage 2+: Background fetch up to 1000 records in batches of 100
- Date-based cursor pagination to avoid race conditions
- Deduplication by record ID to prevent duplicates
- SSE push to clients when history cache is updated
- Shared background fetch state for concurrent user requests
2026-05-21 01:23:11 +01:00
gronod dbdfe3f329 Fix status button ID mismatch - use status-btn instead of status-toggle
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m23s
2026-05-21 01:12:55 +01:00
gronod b9b5d7d393 Increase history pageSize from 100 to 500 to fetch more records
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 55s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m15s
Fixes issue where series beyond position 100 in history were not appearing
in recently completed section.
2026-05-21 01:10:36 +01:00
gronod 7424e70ea6 Add logging for total Sonarr/Radarr records fetched
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Successful in 1m28s
2026-05-21 01:06:28 +01:00
gronod 830dea3d6b Add logging for filtered event types and missing series/movie objects
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m16s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m43s
2026-05-21 01:02:57 +01:00
gronod 4ff462b7f4 Add detailed logging for all series/movies with raw tag IDs to debug missing items
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m2s
CI / Security audit (push) Successful in 1m7s
CI / Tests & coverage (push) Has been cancelled
2026-05-21 01:01:14 +01:00
gronod d9f1fc99a9 Add debugging logs for history filtering to diagnose missing series
Build and Push Docker Image / build (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 30s
CI / Security audit (push) Successful in 44s
CI / Tests & coverage (push) Successful in 55s
2026-05-21 00:58:10 +01:00
gronod 46b42045f1 Fix tab selection - use data-tab attribute instead of missing IDs
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 30s
CI / Security audit (push) Successful in 49s
CI / Tests & coverage (push) Successful in 1m3s
2026-05-21 00:55:18 +01:00
gronod d12356e8f3 fix: update test to reflect that unmatched torrents should not be displayed
Build and Push Docker Image / build (push) Successful in 34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 29s
CI / Security audit (push) Successful in 46s
CI / Tests & coverage (push) Successful in 55s
2026-05-21 00:50:33 +01:00
gronod 6124ec0f5a Exclude public directory from copyright header check
Build and Push Docker Image / build (push) Successful in 30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 39s
CI / Security audit (push) Successful in 44s
CI / Tests & coverage (push) Failing after 54s
The public directory contains minified/bundled build artifacts that
should not require copyright headers.
2026-05-21 00:44:07 +01:00
gronod fd303699db Add file details to copyright header check failure output
Build and Push Docker Image / build (push) Successful in 19s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 37s
CI / Security audit (push) Successful in 52s
CI / Tests & coverage (push) Failing after 53s
When copyright header check fails, now outputs the actual first 5 lines
of each failing file to help diagnose the issue.
2026-05-21 00:40:41 +01:00
gronod 8f19da3ae6 Improve license check to output failing package names
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 33s
CI / Security audit (push) Successful in 55s
CI / Tests & coverage (push) Failing after 1m1s
When license compatibility check fails, now outputs full license report
showing which packages have incompatible licenses.
2026-05-21 00:38:53 +01:00
gronod 3c9dd3ca62 Fix: Remove unmatched torrents from download display
Build and Push Docker Image / build (push) Successful in 12s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 31s
CI / Security audit (push) Successful in 44s
CI / Tests & coverage (push) Failing after 53s
Downloads with no match in Sonarr or Radarr were being displayed
when they should not be. Removed the code in matchTorrents that
was adding unmatched torrents to results.
2026-05-21 00:37:37 +01:00
gronod f02c30efde Fix CSP violations and ignoreAvailable reference error
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 40s
CI / Security audit (push) Successful in 54s
CI / Tests & coverage (push) Successful in 1m7s
- Add .hidden utility class to style.css for CSP compliance
- Replace all inline style='display: none' with class='hidden' in HTML
- Update all UI modules to use classList.add/remove instead of style.display
- Fix ignoreAvailable reference error in history.js (use state.ignoreAvailable)
- Rebuild client bundle with vite
2026-05-21 00:33:57 +01:00
gronod ddebe96056 ci: trigger rebuild with .dockerignore fix
Build and Push Docker Image / build (push) Successful in 55s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m13s
2026-05-21 00:27:29 +01:00
gronod b6367076f9 fix: remove client/ from .dockerignore to fix Docker client-build stage
Build and Push Docker Image / build (push) Successful in 42s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Successful in 1m17s
CI / Security audit (pull_request) Successful in 1m11s
CI / Tests & coverage (pull_request) Successful in 1m33s
2026-05-21 00:16:51 +01:00
gronod 31ed9f02b6 fix: remove client/ from .dockerignore to fix Docker client-build stage
Build and Push Docker Image / build (push) Successful in 48s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-21 00:16:08 +01:00
gronod 3e6af1bff2 Add frontend unit tests with Vitest + jsdom
Licence Check / Licence compatibility and copyright header verification (push) Successful in 38s
CI / Security audit (push) Successful in 53s
CI / Tests & coverage (push) Successful in 1m9s
Build and Push Docker Image / build (push) Failing after 32s
- Create tests/frontend/utils/format.test.js with 24 tests for formatting utilities
- Create tests/frontend/ui/downloads.test.js with 10 tests for DOM rendering functions
- Update vitest.config.js to support jsdom environment for frontend tests
- All 34 tests pass and cover edge cases (null, zero, large numbers, DOM structure)
2026-05-21 00:07:41 +01:00
gronod d9897ff0d2 Extract matching logic into new DownloadMatcher service
Build and Push Docker Image / build (push) Failing after 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m31s
CI / Tests & coverage (push) Successful in 1m46s
2026-05-21 00:04:57 +01:00
gronod 06442c1d75 Add JSDoc comments and defensive error handling to DownloadBuilder.js
Build and Push Docker Image / build (push) Failing after 19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 47s
CI / Security audit (push) Successful in 1m1s
CI / Tests & coverage (push) Successful in 1m12s
2026-05-21 00:00:46 +01:00
gronod 86aaa79339 refactor: Complete technical debt remediation - final cleanup
Build and Push Docker Image / build (push) Failing after 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 48s
CI / Security audit (push) Successful in 59s
CI / Tests & coverage (push) Successful in 1m14s
Deleted obsolete public/app.js (replaced by Vite build)
All backend services extracted and dashboard.js slimmed to 284 lines
Frontend fully migrated to vanilla ES modules
All duplications eliminated
Comprehensive tests added
Vite build wired into Dockerfile
2026-05-20 23:48:10 +01:00
gronod e2a71e65a1 refactor: Complete technical debt remediation (all steps)
Build and Push Docker Image / build (push) Failing after 37s
CI / Security audit (push) Successful in 57s
CI / Tests & coverage (push) Successful in 1m9s
Extracted TagMatcher, DownloadAssembler, DownloadBuilder, and WebhookStatus services
Slimmed dashboard.js from 1360 → 284 lines (pure HTTP layer)
Created server/routes/status.js and mounted at /api/status
Migrated frontend to vanilla ES modules under client/src/
Eliminated all tag-badge and client-logo duplication
Wired Vite build into Dockerfile and removed obsolete public/app.js
Added comprehensive DownloadBuilder regression tests
2026-05-20 23:45:08 +01:00
gronod d03efbf25e Extract createClientLogo helper to eliminate 2× client-logo duplication
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 36s
CI / Security audit (push) Successful in 1m1s
CI / Tests & coverage (push) Successful in 1m15s
- Consolidated duplicate client logo creation logic into single helper function
- Removed second duplicate logo block from createDownloadCard
- Updated updateDownloadCard to use the same helper
- Client logo now appears exactly once per download card
2026-05-20 23:36:16 +01:00
gronod 0b91152ad7 Extract renderTagBadges helper to eliminate 4x tag-badge rendering duplication
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 37s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m19s
2026-05-20 23:33:13 +01:00
gronod 8dc105ff3e Migrate frontend from monolithic app.js to vanilla ES modules
Build and Push Docker Image / build (push) Successful in 35s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 49s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m23s
- Delete React files (App.jsx, main.jsx, App.css)
- Create modular vanilla JS structure in client/src/:
  - state.js (global state object)
  - api.js (all fetch calls)
  - sse.js (SSE connection management)
  - ui/auth.js (authentication UI)
  - ui/downloads.js (downloads rendering)
  - ui/history.js (history section)
  - ui/statusPanel.js (status panel)
  - ui/webhooks.js (webhook management)
  - ui/filters.js (download client filter)
  - ui/theme.js (theme switching)
  - ui/tabs.js (tab navigation)
  - utils/format.js (formatting utilities)
  - utils/storage.js (localStorage helpers)
  - main.js (DOMContentLoaded bootstrap)
- Update vite.config.js for vanilla build outputting to ../public/app.js
- Build succeeds: 14 modules, 43.88 kB output
2026-05-20 23:30:24 +01:00
gronod a38fc4a8ce refactor: extract status route and WebhookStatus service, slim dashboard.js
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m32s
- Extract /status route to server/routes/status.js
- Create server/services/WebhookStatus.js with checkWebhookConfigured and aggregateMetrics
- Slim dashboard.js to pure HTTP orchestration (559→283 lines, 49.4% reduction)
- Remove /user-summary and /webhook-metrics routes from dashboard.js
- Mount status router at /api/status in server/index.js and server/app.js
- Update tests to use new /api/status/status endpoint
- Fix test expectation for speed field (number vs string)

All 571 tests passing.
2026-05-20 22:50:40 +01:00
gronod 2bf4cb2a0f Refactor: Deduplicate download assembly logic into DownloadBuilder service
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 54s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Failing after 1m15s
- Created server/services/DownloadBuilder.js with buildUserDownloads function
- Added private helpers: buildSeriesMapFromRecords, buildMoviesMapFromRecords, matchSabSlots, matchSabHistory, matchTorrents, getSlotStatusAndSpeed
- Updated server/routes/dashboard.js to use buildUserDownloads in /user-downloads and SSE /stream
- Removed ~500 lines of duplicated download-assembly logic
- All unit tests passing (DownloadBuilder: 14, DownloadAssembler: 73, TagMatcher: 26)
2026-05-20 22:43:03 +01:00
gronod d74b46d5b0 Add guard test for DownloadBuilder service
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Tests & coverage (push) Failing after 1m18s
CI / Security audit (push) Successful in 1m27s
2026-05-20 22:34:24 +01:00
gronod 9cffb96f29 Extract DownloadAssembler service from dashboard routes
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Failing after 1m37s
- Create server/services/DownloadAssembler.js with 7 pure functions:
  - getCoverArt, getImportIssues, getSonarrLink, getRadarrLink
  - canBlocklist, extractEpisode, gatherEpisodes
- Update server/routes/dashboard.js to use DownloadAssembler
- Add comprehensive unit tests (73 tests covering edge cases)
- Fix null check in extractEpisode function
- All tests passing: DownloadAssembler (73/73), TagMatcher (26/26)
2026-05-20 22:32:09 +01:00
gronod 4d61dd566f Refactor: Extract tag functions to TagMatcher service
Build and Push Docker Image / build (push) Successful in 21s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 49s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Failing after 1m15s
- Extract six pure tag-related functions from dashboard.js into new server/services/TagMatcher.js
- Functions: sanitizeTagLabel, tagMatchesUser, extractAllTags, extractUserTag, getEmbyUsers, buildTagBadges
- Update dashboard.js to import TagMatcher and replace all inline function calls
- Add comprehensive unit tests in tests/unit/services/TagMatcher.test.js (26 tests passing for 5 pure functions)
- Note: getEmbyUsers tests excluded due to CommonJS mocking complexity
2026-05-20 22:21:01 +01:00
gronod d568800942 fix: limit history pagination to prevent 40s response times
Licence Check / Licence compatibility and copyright header verification (push) Successful in 51s
Build and Push Docker Image / build (push) Successful in 1m27s
CI / Security audit (push) Successful in 1m27s
CI / Tests & coverage (push) Successful in 1m43s
The full pagination fix (ddad80a, e772001) caused history retrieval to
fetch up to 50 pages sequentially, taking 10-40 seconds instead of ~200ms.

Changes:
- Add maxPages parameter to getHistory() with default of 1 page
- Update poller to fetch 50 records (up from 10) in a single API call
- historyFetcher retains 100 records per page for UI display

This provides 5x more history for matching than before while keeping
the fast single-request performance.

Refs: develop-merge pagination performance issue
2026-05-20 21:44:53 +01:00
gronod 7d3e6e6a47 test: add integration and unit tests for dashboard, emby, sonarr, radarr, sabnzbd routes
Build and Push Docker Image / build (push) Successful in 39s
Docs Check / Markdown lint (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Failing after 1m39s
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
- tests/unit/dashboard.test.js: 58 unit tests covering all 12 pure helper
  functions in dashboard.js (sanitizeTagLabel, tagMatchesUser, getCoverArt,
  extractAllTags, extractUserTag, getImportIssues, getSonarrLink, getRadarrLink,
  canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges)

- tests/integration/dashboard.test.js: 35 integration tests for
  /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll, paused queue,
  history matching, importIssues, wrong-user filtering), /status (admin guard,
  webhook check, failure handling), /webhook-metrics, /cover-art (all
  validation/proxy paths), /blocklist-search (guards, Sonarr, Radarr, failure)

- tests/integration/emby.test.js: 13 integration tests covering all 4 Emby
  routes (sessions, users, users/:id, session/:id/user) with auth guard,
  happy path, and upstream failure cases

- tests/integration/arrRoutes.test.js: 64 integration tests for Sonarr +
  Radarr (queue, history, series/movies, notifications CRUD, /test, /schema,
  /sofarr-webhook create+update+missing-config+failure) and SABnzbd (queue,
  history with custom params)

- vitest.config.js: raise global coverage thresholds (statements/functions/
  lines 20->55, branches 8->40) to reflect improved coverage
  (62.5% stmts, 42.6% branches, 64.1% funcs, 65.6% lines)

- tests/README.md: document new test files and update coverage table
2026-05-20 21:37:57 +01:00
Gandalf ee2f275501 Merge pull request 'fix: use stable *arr IDs for matching before fragile title fallback' (#21) from fix-arr-matching into develop-merge
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m2s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m31s
Reviewed-on: #21
2026-05-20 21:02:10 +01:00
Gandalf ca6ff66115 Merge pull request 'fix: webhook replay cache atomicity and instanceName precision' (#22) from fix-webhook-receiver into develop-merge
Build and Push Docker Image / build (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
Reviewed-on: #22
2026-05-20 21:01:52 +01:00
Gandalf 080431c4b7 Merge pull request 'fix: QBittorrent fallback state corruption after full sync' (#23) from fix-qbittorrent-client into develop-merge
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
Reviewed-on: #23
2026-05-20 21:01:36 +01:00
Gandalf f457a708d2 Merge pull request 'fix: SABnzbd speed assignment and size/progress parsing' (#24) from fix-sabnzbd-client into develop-merge
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
Reviewed-on: #24
2026-05-20 21:01:21 +01:00
Gandalf 914ab73d4e Merge pull request 'fix: full pagination + non-silent errors in PollingRadarrRetriever' (#25) from fix-radarr-retriever into develop-merge
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
Reviewed-on: #25
2026-05-20 21:01:07 +01:00
Gandalf 25d8e007a4 Merge pull request 'fix: full pagination + non-silent errors in PollingSonarrRetriever' (#26) from fix-sonarr-retriever into develop-merge
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Reviewed-on: #26
2026-05-20 21:00:53 +01:00
gronod bb7b66e06d fix: use stable *arr IDs for matching before fragile title fallback
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m11s
CI / Tests & coverage (push) Failing after 1m8s
CI / Security audit (push) Successful in 1m11s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 52s
CI / Security audit (pull_request) Successful in 1m18s
CI / Tests & coverage (pull_request) Failing after 1m26s
2026-05-20 20:51:50 +01:00
gronod 5ad525a760 fix: webhook replay cache atomicity and instanceName precision
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Failing after 1m30s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 57s
CI / Security audit (pull_request) Successful in 1m21s
CI / Tests & coverage (pull_request) Failing after 1m36s
2026-05-20 20:46:35 +01:00
gronod 1e162381f4 fix: QBittorrent fallback state corruption after full sync
Licence Check / Licence compatibility and copyright header verification (push) Successful in 54s
CI / Security audit (push) Successful in 1m37s
CI / Tests & coverage (push) Failing after 1m46s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 1m1s
CI / Security audit (pull_request) Successful in 1m29s
CI / Tests & coverage (pull_request) Failing after 1m32s
2026-05-20 20:45:26 +01:00
gronod 42f0481a9a fix: SABnzbd speed assignment and size/progress parsing
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m45s
CI / Security audit (pull_request) Successful in 1m20s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 1m7s
CI / Tests & coverage (pull_request) Successful in 1m42s
2026-05-20 20:44:08 +01:00
gronod ddad80a666 fix: full pagination + non-silent errors in PollingRadarrRetriever
Licence Check / Licence compatibility and copyright header verification (push) Successful in 51s
CI / Tests & coverage (push) Failing after 1m8s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (pull_request) Failing after 4s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 1m6s
CI / Security audit (pull_request) Successful in 1m35s
2026-05-20 20:42:18 +01:00
gronod e772001c3f fix: full pagination + non-silent errors in PollingSonarrRetriever
Licence Check / Licence compatibility and copyright header verification (push) Successful in 43s
CI / Security audit (push) Successful in 59s
CI / Tests & coverage (push) Failing after 1m11s
Licence Check / Licence compatibility and copyright header verification (pull_request) Failing after 4s
CI / Tests & coverage (pull_request) Failing after 1m33s
CI / Security audit (pull_request) Successful in 1m39s
2026-05-20 20:40:48 +01:00
gronod 1f10414498 Update CHANGELOG for v1.5.5
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
Create Release / release (push) Successful in 41s
Build and Push Docker Image / build (push) Successful in 21s
Docs Check / Markdown lint (push) Successful in 32s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Failing after 1m24s
Docs Check / Mermaid diagram parse check (push) Successful in 1m49s
2026-05-20 01:13:01 +01:00
gronod 1e3926b206 Bump version to 1.5.5
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 47s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Failing after 1m10s
2026-05-20 01:11:22 +01:00
gronod 5fde69fcf5 Add speed formatting to display appropriate units (KB/s, MB/s)
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 31s
CI / Security audit (push) Successful in 54s
CI / Tests & coverage (push) Failing after 1m5s
2026-05-20 01:07:52 +01:00
gronod a562cfe9aa Add logging to debug active download identification and speed
Build and Push Docker Image / build (push) Successful in 29s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Failing after 1m18s
2026-05-20 01:00:25 +01:00
gronod 8549746721 Apply overall SABnzbd speed to active download only
Build and Push Docker Image / build (push) Successful in 34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 39s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m13s
2026-05-20 00:58:38 +01:00
gronod 63fc370262 Remove speed from SABnzbd downloads - API doesn't provide per-download speed
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Failing after 1m8s
2026-05-20 00:56:54 +01:00
gronod 6362441dd5 Add logging to debug SABnzbd speed field in slot data
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 52s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m22s
2026-05-20 00:54:26 +01:00
gronod 76f9e87b44 Add logging to investigate SABnzbd slot structure for speed field
Build and Push Docker Image / build (push) Successful in 35s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m27s
2026-05-20 00:51:12 +01:00
gronod 8c461de72a Hide speed when it is 0 to avoid displaying misleading 0 speed
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 44s
CI / Security audit (push) Successful in 1m7s
CI / Tests & coverage (push) Has been cancelled
2026-05-20 00:49:26 +01:00
gronod d11f11be69 Fix missing speed on SAB cards and remove incorrect missing pieces display
Build and Push Docker Image / build (push) Successful in 16s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 29s
CI / Security audit (push) Successful in 59s
CI / Tests & coverage (push) Successful in 59s
2026-05-20 00:47:07 +01:00
gronod 05d11975e6 Reduce card logo size to 32x32
Build and Push Docker Image / build (push) Successful in 41s
CI / Security audit (push) Successful in 57s
CI / Tests & coverage (push) Successful in 1m8s
2026-05-20 00:41:04 +01:00
gronod cd3480c0ce Fix logo positioning by adding position: relative to download-card
Build and Push Docker Image / build (push) Successful in 41s
CI / Security audit (push) Successful in 1m3s
CI / Tests & coverage (push) Successful in 1m11s
2026-05-20 00:39:11 +01:00
gronod 712c98d817 Move card logo to bottom right with absolute positioning, fix duplication
Build and Push Docker Image / build (push) Successful in 23s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 41s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m18s
2026-05-20 00:37:01 +01:00
gronod ff7ace9f4f Fix duplicate icon and user tag on page reload by adding class and duplicate check
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 50s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m44s
2026-05-20 00:29:44 +01:00
gronod 73500751a0 Increase download client logo size in cards to 64x64px (4x), keep filter picker at 20x20px
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 48s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m15s
2026-05-20 00:26:54 +01:00
gronod 82a9df134b Fix duplicate user tag and logo in download cards by removing old elements before updating
Build and Push Docker Image / build (push) Successful in 32s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 30s
CI / Security audit (push) Successful in 50s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-20 00:23:17 +01:00
gronod 67fa79796b Add download client logo to download card with right-side positioning
Build and Push Docker Image / build (push) Successful in 20s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 36s
CI / Security audit (push) Successful in 1m4s
CI / Tests & coverage (push) Successful in 1m10s
2026-05-20 00:20:03 +01:00
gronod f06d945358 Update rtorrent.svg logo
Build and Push Docker Image / build (push) Successful in 48s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m37s
2026-05-20 00:15:46 +01:00
gronod f5883d4929 Add download client logos to filter UI with fallback handling
Build and Push Docker Image / build (push) Successful in 30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m4s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-20 00:14:20 +01:00
gronod 80cf3eaa39 Fix filtering to use both client type and instanceId for unique identification
Build and Push Docker Image / build (push) Successful in 59s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m2s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m32s
2026-05-20 00:00:17 +01:00
gronod 1ab7e52167 Use index-based unique identifiers for download client selection to prevent cross-selection
Build and Push Docker Image / build (push) Successful in 28s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m32s
2026-05-19 23:56:05 +01:00
gronod 544c168b82 Fix duplicate checkbox ID issue causing cross-selection between clients
Build and Push Docker Image / build (push) Successful in 26s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 48s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m26s
2026-05-19 23:51:57 +01:00
gronod 747a14ebd3 Fix double-toggling issue in download client filter
Build and Push Docker Image / build (push) Successful in 1m15s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m0s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m38s
2026-05-19 23:48:29 +01:00
gronod 49d66c07ee Update ARCHITECTURE.md, bump version to 1.5.4, add CHANGELOG entry
CI / Security audit (push) Failing after 23s
Build and Push Docker Image / build (push) Successful in 52s
Docs Check / Markdown lint (push) Successful in 58s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m45s
2026-05-19 23:45:37 +01:00
gronod be791ed044 Add multi-select download client filter with client type display
Build and Push Docker Image / build (push) Successful in 24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-19 23:41:43 +01:00
gronod 7195a09562 Fix SABnzbd size and speed fields in SSE response
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 52s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Successful in 1m49s
2026-05-19 23:34:24 +01:00
gronod 720de6688b Add download client ordering and filtering to active downloads list
Build and Push Docker Image / build (push) Successful in 22s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m5s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m44s
2026-05-19 23:29:38 +01:00
gronod 3e06bdf8cd Update CHANGELOG.md with 1.5.2 and 1.5.3; update README.md version reference
Build and Push Docker Image / build (push) Successful in 28s
Create Release / release (push) Successful in 6s
Docs Check / Markdown lint (push) Successful in 56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 46s
Docs Check / Mermaid diagram parse check (push) Successful in 1m31s
CI / Security audit (push) Successful in 1m50s
CI / Tests & coverage (push) Successful in 1m55s
2026-05-19 23:11:47 +01:00
gronod ca1c136d4f Merge branch 'develop'
Build and Push Docker Image / build (push) Successful in 43s
CI / Security audit (push) Successful in 1m23s
Create Release / release (push) Successful in 11s
CI / Tests & coverage (push) Successful in 1m42s
2026-05-19 23:09:23 +01:00
gronod a04f2c9b25 Bump version to 1.5.3 2026-05-19 23:09:23 +01:00
gronod 743b169989 Fix webhooks panel: hide on app load to sync with status panel
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 38s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-19 23:05:20 +01:00
gronod 794cb7268e Fix status panel: remove innerHTML wipe that destroys status-content div
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m27s
2026-05-19 23:01:14 +01:00
gronod d310d101ed Fix undefined --background CSS variable causing blank status panel
Build and Push Docker Image / build (push) Successful in 44s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m34s
2026-05-19 22:59:16 +01:00
gronod 96f24eb3b7 Fix status card regression: revert webhooks-section to sibling structure
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m14s
2026-05-19 22:57:21 +01:00
gronod abcb9bfded debug: Add DOM structure verification to trace missing contentDiv
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m34s
2026-05-19 22:35:05 +01:00
gronod e5920b207f debug: Add more detailed logging to renderStatusPanel
Build and Push Docker Image / build (push) Successful in 26s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m38s
2026-05-19 22:33:09 +01:00
gronod d3483f3be7 debug(ui): Add visible styling and debug logging for status panel
Build and Push Docker Image / build (push) Successful in 25s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m26s
Added debug logging to trace status panel rendering:
- Log when refresh starts
- Log when data is received
- Log errors with details

Also added visible dashed border and background to #status-content
to make it obvious when the div is present but empty.
2026-05-19 22:30:54 +01:00
gronod 252cc50aa4 fix(ui): Add loading state and min-height for status-content
Build and Push Docker Image / build (push) Successful in 39s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Has been cancelled
Added loading indicator text and min-height CSS for #status-content
to prevent the empty card appearance when status panel first opens.
2026-05-19 22:29:03 +01:00
gronod 57908e2b9e fix(ui): Add status-content container to preserve webhooks panel
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 50s
CI / Security audit (push) Successful in 1m1s
CI / Tests & coverage (push) Successful in 1m19s
The webhooks panel was being destroyed when renderStatusPanel set
panel.innerHTML. Added a dedicated #status-content div for status
data, keeping webhooks section intact when status refreshes.
2026-05-19 22:27:11 +01:00
gronod e2757768c7 fix(ui): Integrate webhooks panel into status panel
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m35s
The webhooks panel was appearing separately from the status panel.
Now it's properly nested inside the status-panel div:

- Moved webhooks-section inside status-panel in HTML
- Updated CSS so nested webhooks looks like a subsection (no double borders)
- Simplified JS toggle logic - webhooks shows/hides automatically with status panel
- Admin users see webhooks inside status panel, collapsed by default
2026-05-19 22:24:15 +01:00
gronod 2469c3e3f4 fix(pagination): Increase Sonarr/Radarr page sizes to fetch all items
Build and Push Docker Image / build (push) Successful in 20s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Successful in 1m27s
Sonarr Activity tab has 12 pages but we only fetched ~2 items.
Added pageSize=1000 to queue API and changed history default from 10 to 100.
This ensures all downloads are available for matching to SAB/qBittorrent.
2026-05-19 22:20:09 +01:00
gronod 6c8c333c6a debug: Add Sonarr queue titles to no-match output
Build and Push Docker Image / build (push) Successful in 49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m0s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Successful in 1m29s
2026-05-19 22:16:26 +01:00
gronod 5dfe0b1216 fix(matching): Match SAB to Sonarr by downloadId first
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m27s
Sonarr tracks the exact SAB download ID (nzo_id). Now tries to match
by downloadId first, then falls back to title matching. Also adds
debug to show if matches are via downloadId vs title, and logs
downloadIds in history to verify the link exists.
2026-05-19 22:13:43 +01:00
gronod 77beef787f debug(matching): Show queue vs history source and history titles
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m30s
When a match is found, logs whether it came from queue or history.
When no match, shows history counts and sample titles to verify
history is being checked properly.
2026-05-19 22:10:34 +01:00
gronod 235a866ec8 fix(matching): Check Sonarr/Radarr history for SAB matches
Build and Push Docker Image / build (push) Successful in 44s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m34s
SAB items often persist after Sonarr has processed them.
Previously only checked the active queue, now also checks
history records so completed downloads still appear.
2026-05-19 22:06:38 +01:00
gronod f1d9de2a92 debug(sonarr): Log all available Sonarr queue fields
Build and Push Docker Image / build (push) Successful in 28s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Successful in 1m39s
Shows title, sourceTitle, series.title, episode.title for
each Sonarr queue item to understand the data structure.
2026-05-19 22:04:11 +01:00
gronod 9d0e31ec9a fix(matching): Normalize dots to spaces for SAB/Sonarr matching
Build and Push Docker Image / build (push) Successful in 13s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 46s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
SAB filenames use dots (dora.the.explorer.s02e08) but Sonarr titles
use spaces (Dora the Explorer - S02E08). Now tries matching with
both formats to improve match rate.

Also logs actual Sonarr titles when no match found for debugging.
2026-05-19 22:02:55 +01:00
gronod 42c3eebf18 debug(sse): Add detailed name matching logging
Build and Push Docker Image / build (push) Successful in 29s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m4s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m49s
Shows exactly which SAB items match/don't match to Sonarr/Radarr:
- ✓ Sonarr match: SAB name → Sonarr name
- ✓ Radarr match: SAB name → Radarr name
- ✗ No match: SAB name (with Sonarr queue count)

This will help diagnose why Sonarr Activity Queue shows matches but Sofarr doesn't.
2026-05-19 21:50:05 +01:00
gronod f295e1c90d debug(sse): Add SAB matching stats to trace filtering
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 49s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m27s
Shows how many SAB items were checked vs how many matched to Sonarr/Radarr.
This will help diagnose why only ~10 of 60 SAB items are appearing.
2026-05-19 21:47:12 +01:00
gronod c5e8281440 fix(sabnzbd): Handle labels as array or string
Build and Push Docker Image / build (push) Successful in 43s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m7s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m47s
SABnzbd API returns labels as an array in newer versions,
but the code assumed it was a comma-separated string.
Now handles both cases to prevent 'slot.labels.split is not a function' error.
2026-05-19 21:43:58 +01:00
gronod f22dd0d1f6 fix(downloads): Fix SABnzbd/qBittorrent collision and webhook metrics
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m41s
1. Fixed download client collision:
   - SABnzbd client with id 'i3omb' was being overwritten by qBittorrent
   - Now uses unique key ':' like the arr retrievers

2. Fixed webhook metrics showing 0:
   - instanceName from webhooks is generic ('Sonarr', 'Radarr')
   - Not the configured instance name ('i3omb')
   - Now updates metrics for ALL instances of that type
2026-05-19 21:40:53 +01:00
gronod 5159a83475 fix(retrievers): Use unique key to prevent Sonarr/Radarr collision
Build and Push Docker Image / build (push) Successful in 33s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m10s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m52s
When Sonarr and Radarr had the same instance ID (e.g., 'i3omb'),
the Radarr retriever would overwrite the Sonarr retriever in the Map.
This caused webhook refreshes to show '0 instance(s)' for Sonarr.

Now uses ':' as the unique key so both can coexist.
2026-05-19 21:36:20 +01:00
gronod ccc3b6ffec fix(status): Check actual webhook config, show enabled even with 0 events
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
The status panel was showing webhooks as disabled (null) when no events
had been received yet. Now it checks Sonarr/Radarr API to see if the
Sofarr webhook notification is actually configured.

- Added checkWebhookConfigured() to verify webhook exists in Sonarr/Radarr
- Shows 'enabled: true' with 0 events when webhook is configured
- Only shows null when webhook is not configured at all
2026-05-19 21:35:26 +01:00
gronod 4ec7d734b8 debug(sse): Add detailed logging for download matching
Build and Push Docker Image / build (push) Successful in 34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m38s
Add debug logging to trace:
- When downloads payload is built
- Data sizes from cache (SAB, qBit, Sonarr, Radarr)
- Number of downloads found and their titles

This will help diagnose why Dora downloads aren't appearing.
2026-05-19 21:32:15 +01:00
gronod 2e85fae57a fix(webhooks): Load collapsed by default, add webhook metrics to status panel
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m53s
Build and Push Docker Image / build (push) Successful in 35s
- Fixed webhooks section to load collapsed (content hidden, toggle arrow reset)
- Added webhook metrics card to status panel for admin users:
  - Shows Sonarr/Radarr enabled/disabled status
  - Shows events received and polls skipped counts
- Updated /api/dashboard/status endpoint to include webhook metrics
- Metrics are aggregated from all Sonarr/Radarr instances
2026-05-19 21:24:28 +01:00
gronod aeacadbe68 refactor(webhooks): Integrate webhooks panel into status card
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m33s
- Moved webhooks-section to be inline with status-panel in HTML
- Updated toggleStatusPanel() to show/hide webhooks section for admin users
- Updated closeStatusPanel() to also hide webhooks section
- Removed webhooks visibility from showDashboard() - now tied to status panel
- Updated CSS to make webhooks section styling consistent with status panel:
  - Same border, border-radius, margin, box-shadow
  - Updated webhook-stats to use status-card styling (background, border)
- Webhooks metrics now display inline with status panel for admin users
2026-05-19 21:20:34 +01:00
gronod 3ef35a8c43 fix(webhooks): Send full notification object to test endpoint
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m42s
The /notifications/test endpoint requires the full notification object,
not just the ID. Changed testSonarrWebhook() and testRadarrWebhook() to
send the complete notification object (sonarrSofarr/radarrSofarr).

Fixes: 400 validation error when testing webhooks
2026-05-19 21:16:31 +01:00
gronod 0f3c02e52d fix(webhooks): Use numeric method value (1=POST) in notification payload
Build and Push Docker Image / build (push) Successful in 44s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m33s
The webhook notification payload was using string 'POST' for the method
field, but Sonarr/Radarr API expects numeric values:
- 1 = POST
- 2 = PUT

Also added onManualInteractionRequired: false to match the schema.

Fixes: Radarr/Sonarr rejecting webhook configuration with validation errors
2026-05-19 20:47:19 +01:00
gronod 9fd60bcfed fix(webhooks): Use SONARR_INSTANCES/RADARR_INSTANCES config for notification routes
Build and Push Docker Image / build (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m36s
The notification routes were using process.env.SONARR_URL directly,
which is undefined when using the newer SONARR_INSTANCES JSON format.

Changes:
- Added getFirstSonarrInstance() and getFirstRadarrInstance() helpers
- Updated /notifications, /notifications/test, and /notifications/sofarr-webhook
  routes to use instance config from getSonarrInstances()/getRadarrInstances()
- Returns 503 error if no instances are configured

Fixes: 'Invalid URL' errors when calling Sonarr/Radarr notification APIs
2026-05-19 20:42:59 +01:00
gronod af58e1bf2a debug(webhooks): Add console.error logging to Sonarr/Radarr notification routes
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m34s
Added detailed error logging to help diagnose 500 errors when calling
Sonarr/Radarr notification APIs. Logs include:
- Error message
- Response status (if available)
- Response data (if available)

This will help identify if the issue is:
- Missing SONARR_URL/RADARR_URL or API keys
- Network connectivity issues
- Sonarr/Radarr API version incompatibility
2026-05-19 20:39:37 +01:00
gronod 2d04402284 fix(webhooks): Show webhooks panel only to admin users
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m4s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 20:36:33 +01:00
gronod 0310f10e5d fix(webhooks): Restore original vanilla JS app and add webhooks panel properly
Build and Push Docker Image / build (push) Successful in 1m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m22s
CI / Tests & coverage (push) Successful in 1m43s
The React build replaced the full-featured vanilla JS app with a
simpler UI, causing the dashboard to disappear and lose theming.

This commit:
- Restores original vanilla JS app with auth, themes, tabs, history
- Adds Webhooks Configuration panel for admin users
- Adds webhook status, enable/test buttons, triggers, and stats
- Uses proper CSS variables for theme support

Fixes the dashboard disappearing issue and restores all original functionality.
2026-05-19 20:33:23 +01:00
gronod 5ab8cc96a3 Merge branch 'develop'
Create Release / release (push) Successful in 18s
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m30s
2026-05-19 20:27:26 +01:00
gronod a7363fcb3a v1.5.2: Build and deploy React client with Webhooks Configuration panel
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m6s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m46s
2026-05-19 20:27:11 +01:00
gronod d06e24dbb6 feat(webhooks): display webhook statistics (events received, polls skipped, last event) in status panel
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 19:18:29 +01:00
gronod 6df94e5ad2 Merge branch 'develop' into main — release 1.5.1
CI / Security audit (push) Successful in 1m31s
CI / Tests & coverage (push) Successful in 1m42s
2026-05-19 19:08:03 +01:00
gronod 015e07ae7a Merge hotfix: webhook routing + version 1.5.1
Docs Check / Markdown lint (push) Successful in 34s
Build and Push Docker Image / build (push) Successful in 1m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m52s
Docs Check / Mermaid diagram parse check (push) Successful in 2m5s
CI / Tests & coverage (push) Successful in 2m10s
2026-05-19 19:07:12 +01:00
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 740b03ac85 Merge branch 'develop' into main — release 1.5.0a
Build and Push Docker Image / build (push) Successful in 1m1s
Create Release / release (push) Successful in 25s
CI / Security audit (push) Successful in 1m38s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-19 18:52:02 +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 575688dab7 Merge branch 'develop' into main — release 1.5.0
Create Release / release (push) Successful in 18s
Build and Push Docker Image / build (push) Successful in 39s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 18:42:34 +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 84658102e0 Merge branch 'develop'
Create Release / release (push) Successful in 12s
Build and Push Docker Image / build (push) Successful in 32s
CI / Security audit (push) Successful in 42s
CI / Tests & coverage (push) Successful in 58s
Implements PDCA feature
2026-05-19 14:58:45 +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 ae9e877445 Merge branch 'main' of https://git.i3omb.com/Gandalf/sofarr
Create Release / release (push) Successful in 30s
CI / Security audit (push) Successful in 1m38s
CI / Tests & coverage (push) Successful in 1m52s
2026-05-19 09:07:59 +01:00
gronod 853b205c46 Merge develop: Add MIT copyright headers 2026-05-19 09:07:51 +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 65b9f0f395 Merge pull request 'fix: documentation update' (#15) from develop into main
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 47s
CI / Tests & coverage (push) Successful in 54s
Reviewed-on: #15
2026-05-18 08:02:30 +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
gronod 20dfe06866 Merge branch 'develop'
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m40s
Create Release / release (push) Successful in 9s
Build and Push Docker Image / build (push) Successful in 18s
2026-05-18 06:35:46 +01:00
gronod a0f630fb81 chore: bump version to 1.3.1 (point release)
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Dependency licence compatibility (push) Successful in 48s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Successful in 1m10s
2026-05-18 06:35:16 +01:00
gronod e640215502 chore: bump version to 1.4.0
Licence Check / Dependency licence compatibility (push) Successful in 1m5s
Build and Push Docker Image / build (push) Successful in 1m17s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m31s
2026-05-18 06:31:31 +01:00
gronod 972b407956 chore: sync package-lock.json version to 1.3.0 2026-05-18 06:30:57 +01:00
gronod cf7008fd54 docs: update documentation for blocklist & search non-admin eligibility
Build and Push Docker Image / build (push) Successful in 29s
Docs Check / Markdown lint (push) Successful in 50s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Successful in 1m50s
Docs Check / Mermaid diagram parse check (push) Successful in 2m18s
- CHANGELOG: document button availability changes (all admin downloads, non-admin eligibility)
- README: update blocklist-search endpoint description with non-admin conditions
- ARCHITECTURE.md: update Authorisation Matrix, Download object table (add canBlocklist, addedOn fields), and blocklist-search API reference
2026-05-18 00:05:31 +01:00
gronod 2747ca7754 feat: allow non-admin users to blocklist & search under specific conditions
Build and Push Docker Image / build (push) Successful in 37s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Successful in 1m47s
- Added addedOn timestamp to qBittorrent torrent mapping
- Added canBlocklist helper function: true for admins, true for non-admins when (importIssues OR (torrent >1h old AND availability<100%))
- Added canBlocklist field to all download objects in /user-downloads and SSE /stream routes (8 blocks total)
- Frontend button now shows when (isAdmin OR download.canBlocklist) && download.arrQueueId
2026-05-17 23:57:06 +01:00
gronod 0341540751 feat: show blocklist & search button on all admin downloads (not just import-pending)
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m43s
- Remove importIssues condition from arr action fields threading in /user-downloads route (all 4 blocks: SAB+Sonarr, SAB+Radarr, qBit+Sonarr, qBit+Radarr)
- Remove importIssues condition from arr action fields threading in SSE /stream route (all 4 blocks)
- Move blocklist button rendering outside importIssues condition in frontend — now shows for all admin downloads with arrQueueId
2026-05-17 23:43:37 +01:00
gronod 3bb9e936c3 release: v1.3.0
Build and Push Docker Image / build (push) Successful in 50s
CI / Security audit (push) Successful in 2m34s
CI / Tests & coverage (push) Successful in 2m3s
2026-05-17 23:29:12 +01:00
gronod aef21d1b50 chore: bump to v1.3.0; update CHANGELOG, README, ARCHITECTURE docs
Docs Check / Mermaid diagram parse check (push) Failing after 44s
Docs Check / Markdown lint (push) Successful in 1m7s
Build and Push Docker Image / build (push) Successful in 1m15s
Licence Check / Dependency licence compatibility (push) Successful in 1m37s
CI / Security audit (push) Successful in 2m2s
CI / Tests & coverage (push) Successful in 2m27s
2026-05-17 23:29:02 +01:00
gronod a6fcde58cf fix: thread arr action fields through SSE handler; align import-issue tooltip with themed CSS pattern
Build and Push Docker Image / build (push) Successful in 31s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m36s
2026-05-17 23:20:04 +01:00
gronod d839fa98a0 feat: blocklist & search button for import-pending downloads with caution
Build and Push Docker Image / build (push) Successful in 29s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m42s
- Poller now stores _instanceKey alongside _instanceUrl on Sonarr/Radarr queue records
- dashboard route threads arrQueueId/arrType/arrInstanceUrl/arrInstanceKey/arrContentId/arrContentType as admin-only fields on downloads with importIssues
- POST /api/dashboard/blocklist-search: admin-only, removes queue item with blocklist=true then triggers EpisodeSearch/MoviesSearch
- Button renders in download card header (admin + importIssues + arrQueueId only)
- Confirm dialog, loading/success/error states on the button
- Kicks a background poll on success so SSE reflects removed item promptly
2026-05-17 23:15:33 +01:00
gronod a92ab85bc0 fix: title link wired via JS goHome() — switches to downloads, closes status, resets showAll
Build and Push Docker Image / build (push) Successful in 40s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m56s
2026-05-17 23:08:27 +01:00
gronod 57b127ea95 fix: title click switches to downloads tab and closes status panel (no page reload)
Build and Push Docker Image / build (push) Successful in 33s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m37s
2026-05-17 23:01:15 +01:00
gronod 56f42755cc fix: title logo links to /, version footer links to repo
Build and Push Docker Image / build (push) Successful in 26s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m36s
2026-05-17 22:58:53 +01:00
gronod 15152714fd fix: use data-tooltip CSS popup for hide-upgrade-failures checkbox, matching episode tooltip style
Build and Push Docker Image / build (push) Successful in 26s
CI / Security audit (push) Successful in 1m35s
CI / Tests & coverage (push) Successful in 1m44s
2026-05-17 22:55:52 +01:00
gronod 19b9c97e64 feat: add 'Hide upgrade failures' checkbox to history controls
Build and Push Docker Image / build (push) Successful in 36s
CI / Security audit (push) Successful in 1m27s
CI / Tests & coverage (push) Successful in 1m43s
2026-05-17 22:52:55 +01:00
gronod 55a5577f2a feat: render availableForUpgrade badge on failed history items where episode/movie is already on disk
Build and Push Docker Image / build (push) Successful in 38s
CI / Security audit (push) Successful in 1m41s
CI / Tests & coverage (push) Successful in 1m49s
2026-05-17 21:53:58 +01:00
gronod 6139095444 feat: deduplicate history — suppress failed records superseded by successful import, flag failed+hasFile as availableForUpgrade
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 1m14s
Licence Check / Dependency licence compatibility (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 2m16s
2026-05-17 21:52:55 +01:00
116 changed files with 17293 additions and 4311 deletions
+3 -1
View File
@@ -1,3 +1,4 @@
# Docker build context ignores
node_modules/
.env
.env.example
@@ -7,7 +8,8 @@ node_modules/
.DS_Store
*.log
**/*.log
client/
client/node_modules/
client/dist/
dist/
build/
coverage/
+46
View File
@@ -19,6 +19,36 @@ LOG_LEVEL=info
# Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here
# =============================================================================
# WEBHOOK SETTINGS
# =============================================================================
# Secret for validating incoming webhooks from Sonarr and Radarr
# Required for webhook endpoints to accept requests
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
# Generate with: openssl rand -hex 32
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Public base URL of Sofarr (for webhook configuration)
# Required for the one-click webhook setup endpoints
# Sonarr/Radarr need this URL to know where to send webhook events
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# --- Webhook Polling Optimization (Phase 5) ---
# Minutes of silence after which the poller falls back to a full poll
# even if webhooks were recently active. Default: 10 minutes.
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
# reduce background polling on very stable setups.
# WEBHOOK_FALLBACK_TIMEOUT=10
# When an instance has received a recent webhook event, the poller skips
# its queue/history fetch entirely (saving API calls). If you still want
# a periodic poll even with webhooks, set this to 1 to disable skipping.
# Default behaviour: skip polling for instances with recent webhook activity.
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
# =============================================================================
# TLS / HTTPS
# =============================================================================
@@ -94,6 +124,17 @@ QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","u
# QBITTORRENT_USERNAME=admin
# QBITTORRENT_PASSWORD=your-password
# =============================================================================
# RTORRENT_INSTANCES (JSON Array)
# The url MUST include the full XML-RPC endpoint path.
# Standard/self-hosted installs: .../RPC2
# whatbox.ca users: .../xmlrpc
# Other installations may use different custom paths.
# Example:
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent.local:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca:
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# =============================================================================
# SONARR INSTANCES (JSON Array Format)
# Add one or more Sonarr instances as a single-line JSON array
@@ -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
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
# push updates from Sonarr/Radarr and automatically reduce polling load.
# Use the Webhooks Configuration panel in the dashboard UI to enable them
# with one click. The secret must match the header value in each *arr
# notification connection (X-Sofarr-Webhook-Secret).
# =============================================================================
+6 -4
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- 'release/**'
- 'develop'
- 'develop*'
jobs:
build:
@@ -20,9 +20,11 @@ jobs:
BRANCH=${GITHUB_REF#refs/heads/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [ "$BRANCH" = "develop" ]; then
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
echo "Building develop image (version ${VERSION})"
if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
else
RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}"
+64 -4
View File
@@ -7,16 +7,24 @@ on:
- "package.json"
- "package-lock.json"
- ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
pull_request:
branches: ["**", "!main", "!release/**"]
paths:
- "package.json"
- "package-lock.json"
- ".gitea/workflows/licence-check.yml"
- "**/*.js"
- "**/*.ts"
- "**/*.jsx"
- "**/*.tsx"
jobs:
licence-check:
name: Dependency licence compatibility
name: Licence compatibility and copyright header verification
runs-on: ubuntu-latest
continue-on-error: true
steps:
@@ -32,7 +40,59 @@ jobs:
- name: Check licence compatibility
run: |
npx --yes license-checker --production \
# First, output all production licenses for visibility
echo "Checking production dependency licenses..."
npx --yes license-checker --production --excludePrivatePackages --json > /tmp/licenses.json
# Check for incompatible licenses
if ! npx --yes license-checker --production \
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
--excludePrivatePackages \
&& echo "All production dependency licences are compatible with MIT."
--excludePrivatePackages; then
echo ""
echo "❌ Found incompatible licenses. Full license report:"
cat /tmp/licenses.json
exit 1
fi
echo "✅ All production dependency licences are compatible with MIT."
- name: Check copyright headers in source files
run: |
#!/bin/bash
set -e
# Find all source files, excluding build artifacts and node_modules
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
! -path "./node_modules/*" \
! -path "./.git/*" \
! -path "./dist/*" \
! -path "./build/*" \
! -path "./public/*" \
! -path "./.gitea/*")
MISSING_HEADER=0
# Check each file for MIT-compliant copyright header
while IFS= read -r file; do
if [ -z "$file" ]; then
continue
fi
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
echo "❌ Missing MIT-compliant copyright header in: $file"
echo " Required format: // Copyright (c) YYYY Name. MIT License."
echo " Actual first 5 lines:"
head -n 5 "$file" | sed 's/^/ /'
echo ""
MISSING_HEADER=$((MISSING_HEADER + 1))
fi
done <<< "$SOURCE_FILES"
if [ $MISSING_HEADER -gt 0 ]; then
echo ""
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
exit 1
else
echo "✅ All source files have MIT-compliant copyright headers."
fi
+1085
View File
File diff suppressed because it is too large Load Diff
+196
View File
@@ -6,6 +6,202 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
---
## [1.6.0] - 2026-05-21
### Added
- **Staged history loading with SSE push** — history data from Sonarr/Radarr is now fetched in stages; `history-update` SSE events push incremental results to the dashboard so the "Recently Completed" tab populates without waiting for the full fetch.
- **Frontend unit tests** — added Vitest + jsdom test suite covering `client/src/` modules (formatters, storage, state).
- **Comprehensive tests for staged history loading** — backend tests verify the new background-fetch behaviour, cache TTL handling, and SSE emission.
- **Integration test coverage** — new integration and unit tests for `dashboard`, `emby`, `sonarr`, `radarr`, and `sabnzbd` routes.
### Changed
- **Technical-debt remediation — service extraction** — extracted matching and assembly logic from the monolithic `dashboard.js` (~1,360 lines → ~284 lines) into dedicated, testable services:
- `DownloadMatcher.js` — SABnzbd slot / torrent → *arr record matching
- `DownloadAssembler.js` — cover-art, episode, link, blocklist-eligibility helpers
- `DownloadBuilder.js` — orchestrator that reads the cache snapshot and builds the final user-facing download list
- `TagMatcher.js` — tag extraction, sanitisation, and user-ownership matching
- `WebhookStatus.js` — webhook configuration status aggregation
- **Frontend architecture** — migrated from a monolithic `public/app.js` to vanilla ES modules under `client/src/`, bundled by Vite into `public/app.js`.
- **History pagination** — replaced custom date-based cursor pagination with the retriever's built-in pagination (`pageSize=100`, up to 10 pages / 1,000 records total). Eliminates the 40-second response-time regression seen with large histories.
- **Status endpoint path** — admin status route moved from `/api/status/status` to `/api/status` for consistency with the router mount point.
- **Background-fetch safety** — the poller no longer overwrites the cache with empty data when a background history fetch fails or returns no records.
- **SABnzbd progress calculation** — progress is now computed from `slot.mb` and `slot.mbleft` / `slot.mbmissing` because the SABnzbd queue API does not expose a `percentage` field.
- **Speed formatting consistency** — `updateDownloadCard()` now calls `formatSpeed()` so all speed values display with uniform units (B/s, KB/s, MB/s, GB/s).
- **Status-panel error handling** — the panel now surfaces error messages (e.g. `403` for non-admin users) instead of showing a blank box.
### Fixed
- **CSRF token reference errors** — fixed `ReferenceError` bugs in `api.js`, `auth.js`, and the blocklist handler where `csrfToken` and `currentUser` were accessed as bare variables instead of via the shared `state` object.
- **Logout button** — fixed undefined variable references in `handleLogoutClick` that prevented the logout flow from completing.
- **Missing progress bar for SABnzbd** — SABnzbd downloads now render a correct progress bar instead of showing `undefined%`.
- **Status route 404** — corrected the Express router mount so `GET /api/status` responds instead of returning HTML 404.
- **Status button DOM ID** — fixed element-ID mismatch (`status-btn` vs `status-toggle`) that prevented the admin status button from toggling the panel.
- **Tab selection** — fixed tab switching to use `data-tab` attributes after the DOM IDs were removed during the ES-module migration.
- **CSP violations and `ignoreAvailable` reference error** — resolved frontend CSP compliance issues and a missing-variable error in the history tab.
- **Docker client-build stage** — removed `client/` from `.dockerignore` so the multi-stage Dockerfile can run the Vite build during image creation.
- **Unmatched torrent exclusion** — torrents that cannot be matched to a Sonarr/Radarr record are now correctly omitted from the download display.
- **Blocklist button CSRF** — fixed a `ReferenceError` that occurred when a non-admin user clicked the blocklist button.
### Breaking Changes
- **Unmatched torrents are no longer displayed** — torrents that do not match a Sonarr/Radarr queue or history record are excluded from the dashboard. Users who previously saw direct torrent-client downloads will no longer see them unless the *arr services track the item.
- **Frontend build process changed** — the monolithic `public/app.js` source file has been replaced by a Vite build from `client/src/`. Any custom deployment scripts that copy or modify `public/app.js` directly must be updated to run `npm run build` (or use the provided Dockerfile, which already does this).
- **Status API endpoint path changed** — the admin status endpoint moved from `/api/status/status` to `/api/status`. External integrations, monitoring checks, or scripts hitting the old path will receive `404`.
---
## [1.5.5] - 2026-05-20
### Added
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
### Fixed
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
---
## [1.5.4] - 2026-05-19
### Added
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
### Fixed
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
---
## [1.5.3] - 2026-05-19
### Fixed
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
---
## [1.5.2] - 2026-05-19
### Fixed
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
---
## [1.5.1] - 2026-05-19
### Fixed
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
---
## [1.5.0a] - 2026-05-19
### Fixed
- **Status panel close button** — the `×` button now correctly hides the status panel and stops the auto-refresh timer. The button was previously using an inline `onclick` attribute which was silently blocked by the server's CSP nonce policy. Replaced with `addEventListener` wired after `innerHTML` is set, consistent with all other button handlers in the application.
---
## [1.5.0] - 2026-05-19
### Changed
- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline.
- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`.
---
## [1.4.0] - 2026-05-19
### Added
#### Webhook Integration (Phases 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
### Added
- **History tab** — new "Recently Completed" tab showing imported and failed downloads from Sonarr/Radarr history for the last N days (configurable via the days input, persisted in `localStorage`). Auto-refreshes every 5 minutes.
- **History deduplication** — when a failed download has subsequently been imported successfully, only the successful record is shown. If the most recent record for an item is a failure but the episode/movie is already on disk (upgrade attempt), the record is flagged as `availableForUpgrade`.
- **"Upgrade available" badge** — failed history cards where the content is already on disk display an amber badge to indicate this is a failed upgrade rather than a missing item.
- **"Hide upgrade failures" toggle** — checkbox in the history tab to filter out failed records that are already available on disk. State persists in `localStorage`. Tooltip explains the behaviour and matches the episode/multi-episode tooltip style.
- **Blocklist & Search button** — admin-only button on download cards with an "Import Pending" caution. Removes the download from the client with `blocklist=true` (preventing re-grab of the same release) then immediately triggers an `EpisodeSearch`/`MoviesSearch` command in Sonarr/Radarr. Shows a confirmation dialog, loading/success/error states. Kicks a background poll on success.
- **`POST /api/dashboard/blocklist-search`** — new admin-only endpoint backing the above button. Accepts `arrQueueId`, `arrType`, `arrInstanceUrl`, `arrInstanceKey`, `arrContentId`, `arrContentType`.
- **Title link home navigation** — the sofarr logo/title in the header now navigates to the default view (Active Downloads tab, close status panel, reset "Show all users" toggle) without a page reload.
- **Version footer link** — the version string in the dashboard footer links to the source repository.
### Changed
- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned.
- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style.
- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup.
- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability.
- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download.
- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks.
---
## [1.2.2] - 2026-05-17
### Changed
+13
View File
@@ -9,6 +9,18 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Stage 1.5 — client-build: build frontend with Vite
# ---------------------------------------------------------------------------
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# ---------------------------------------------------------------------------
# Stage 2 — runtime image (minimal attack surface)
# ---------------------------------------------------------------------------
@@ -33,6 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
# Copy application source owned by root (read-only at runtime)
COPY --chown=root:root server/ ./server/
COPY --chown=root:root public/ ./public/
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
COPY --chown=root:root package.json ./
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
+123 -17
View File
@@ -4,39 +4,76 @@
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
Version 1.5.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
sofarr connects to your media stack and shows you a personalized view of:
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent, Transmission, rTorrent)
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
- **Multi-Instance Support** - Connect to multiple instances of each service
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
## How It Works
### Architecture Overview
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads)
│ (User) │◀────│ Server │ │ qBittorrent (Torrents)
└─────────────┘ └──────────────┘ Sonarr (TV management)
│ │ Radarr (Movie management)
│ │ Emby (User authentication)
▼ └─────────────────────────────┘
┌──────────────┐
Dashboard
│ Aggregator │
└──────────────┘
┌─────────────┐ ┌──────────────────────────────────────────────┐
│ Browser │────▶│ sofarr Server
│ (User) │◀────│ Auth · Dashboard · History · Webhooks
└─────────────┘
SSE push ◀───────│ Poller (smart: skips when webhooks active)
│ Cache · PDCA Download Registry · PALDRA
└───┬─────────────────────────┬────────────────┘
│ polls (background) │ receives webhooks
┌──────────────────────────┐ ┌─────────▼───────────────────┐
│ Download Clients │ │ *arr Services │
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
│ Transmission (Torrent) │ └─────────────────────────────┘
│ rTorrent (Torrent) │
└──────────────────────────┘
Emby / Jellyfin
(User authentication)
```
**Three pluggable layers power sofarr:**
| Layer | Name | What it does |
|-------|------|--------------|
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
### Webhooks
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
**Quick setup:**
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
2. Open the sofarr dashboard → **Webhooks Configuration** panel
3. Click **Enable** next to each Sonarr/Radarr instance
4. sofarr auto-configures the notification connection inside each *arr service
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
- `POST /api/webhook/sonarr` — receives Sonarr events
- `POST /api/webhook/radarr` — receives Radarr events
### The Matching Process
1. **User Authentication**: Login via Emby credentials
2. **Tag-Based Matching**:
2. **Tag-Based Matching**:
- Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon")
- sofarr checks Sonarr/Radarr activity to find items tagged with your name
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity
- Downloads (from SABnzbd, qBittorrent, Transmission, or rTorrent) are matched by title to that activity
- Only your downloads appear on your dashboard
### Multi-Instance Support
@@ -52,7 +89,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
## Prerequisites
- **Docker** (recommended), or Node.js (v22+) for manual installation
- At least one of: SABnzbd or qBittorrent
- At least one download client: SABnzbd, qBittorrent, Transmission, or rTorrent
- Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking)
- Emby (for user authentication)
@@ -107,6 +144,8 @@ docker run -d \
-e RADARR_INSTANCES='[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]' \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e TRANSMISSION_INSTANCES='[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]' \
-e RTORRENT_INSTANCES='[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]' \
-e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest
@@ -130,6 +169,8 @@ services:
- RADARR_INSTANCES=[{"name":"main","url":"http://radarr:7878","apiKey":"your-key"}]
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091","username":"admin","password":"pass"}]
- RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
- LOG_LEVEL=info
- POLL_INTERVAL=5000
```
@@ -187,6 +228,30 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
# 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)
All services support multi-instance configuration via single-line JSON arrays:
@@ -198,10 +263,21 @@ SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey
# qBittorrent Instances (uses username/password, not API key)
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
# Transmission Instances (uses username/password)
TRANSMISSION_INSTANCES=[{"name":"main","url":"http://transmission:9091/transmission/rpc","username":"admin","password":"pass"}]
# rTorrent Instances (uses username/password, URL must include full RPC endpoint)
# Standard installs use /RPC2. Some providers like whatbox.ca use /xmlrpc.
# No path is automatically appended - always include the full RPC endpoint.
RTORRENT_INSTANCES=[{"name":"main","url":"http://rtorrent:8080/RPC2","username":"rtorrent","password":"rtorrent"}]
# For whatbox.ca (example):
# RTORRENT_INSTANCES=[{"name":"whatbox","url":"https://user.whatbox.ca/xmlrpc","username":"user","password":"pass"}]
# Sonarr Instances
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
# Radarr Instances
# Radarr Instances
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
# Emby (single instance for authentication)
@@ -215,6 +291,18 @@ If you only have one instance, you can use the legacy format:
```bash
SABNZBD_URL=https://sabnzbd.example.com
SABNZBD_API_KEY=your-api-key
QBITTORRENT_URL=https://qbittorrent.example.com
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=secret
TRANSMISSION_URL=http://transmission:9091/transmission/rpc
TRANSMISSION_USERNAME=admin
TRANSMISSION_PASSWORD=pass
RTORRENT_URL=http://rtorrent:8080/RPC2
RTORRENT_USERNAME=rtorrent
RTORRENT_PASSWORD=rtorrent
```
## Setting Up User Tags
@@ -279,6 +367,24 @@ sofarr polls all configured services in the background and caches the results. D
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
- `GET /api/dashboard/cover-art` — Proxied cover art image
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%)
### History
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
### 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)
- `GET /api/sabnzbd/*` — SABnzbd API proxy
@@ -323,7 +429,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
npm run test:ui # interactive Vitest UI
```
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
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
+17 -1
View File
@@ -4,8 +4,10 @@
| Version | Supported |
|---------|-----------|
| 1.4.x | ✅ Yes |
| 1.3.x | ✅ Yes |
| 1.2.x | ✅ Yes |
| 1.1.x | ✅ Yes |
| 1.1.x | ❌ No |
| 1.0.x | ❌ 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 |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| 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
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
### Webhook-Specific (if using webhook integration)
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
### Recommended
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
@@ -145,6 +160,7 @@ server {
|----------|-------|
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
| All `/api/*` routes | 300 requests per 15 min per IP |
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
---
-306
View File
@@ -1,306 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.app-header {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.app-header h1 {
color: #333;
font-size: 2rem;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
background: #f0f0f0;
padding: 10px 20px;
border-radius: 20px;
}
.user-label {
color: #666;
font-weight: 500;
}
.user-name {
color: #667eea;
font-weight: bold;
font-size: 1.1rem;
}
.controls {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.controls label {
color: #333;
font-weight: 500;
}
.session-select {
flex: 1;
min-width: 200px;
padding: 10px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
}
.session-select:focus {
outline: none;
border-color: #667eea;
}
.refresh-btn {
padding: 10px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background 0.3s;
}
.refresh-btn:hover {
background: #5568d3;
}
.error-message {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
border-left: 4px solid #c33;
}
.loading {
text-align: center;
padding: 40px;
color: white;
font-size: 1.2rem;
}
.downloads-container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.downloads-container h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.no-downloads {
text-align: center;
padding: 40px;
color: #666;
}
.no-downloads p {
margin: 10px 0;
}
.downloads-list {
display: grid;
gap: 20px;
}
.download-card {
border: 2px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
gap: 20px;
align-items: flex-start;
}
.download-cover {
flex-shrink: 0;
width: 80px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.download-cover img {
width: 100%;
height: auto;
display: block;
}
.download-info {
flex: 1;
min-width: 0;
}
.download-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
}
.download-card.series {
border-left: 4px solid #667eea;
}
.download-card.movie {
border-left: 4px solid #f093fb;
}
.download-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.download-type {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.download-type.series {
background: #e8eaf6;
color: #667eea;
}
.download-type.movie {
background: #fce4ec;
color: #f093fb;
}
.download-status {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
text-transform: capitalize;
}
.download-status.downloading {
background: #e8f5e9;
color: #4caf50;
}
.download-status.completed {
background: #e3f2fd;
color: #2196f3;
}
.download-status.failed {
background: #ffebee;
color: #f44336;
}
.download-title {
color: #333;
margin-bottom: 10px;
font-size: 1.2rem;
}
.download-series,
.download-movie {
color: #666;
margin-bottom: 15px;
font-style: italic;
}
.download-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-label {
color: #999;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
color: #333;
font-weight: 500;
}
.app-footer {
margin-top: 20px;
text-align: center;
color: white;
font-size: 0.9rem;
}
.app-footer p {
opacity: 0.9;
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.download-details {
grid-template-columns: 1fr;
}
}
-187
View File
@@ -1,187 +0,0 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
function App() {
const [sessionId, setSessionId] = useState('');
const [currentUser, setCurrentUser] = useState(null);
const [downloads, setDownloads] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]);
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const response = await axios.get('/api/emby/sessions');
setSessions(response.data);
// Auto-select first active session
const activeSession = response.data.find(s => s.NowPlayingItem || s.Active);
if (activeSession) {
setSessionId(activeSession.Id);
fetchUserDownloads(activeSession.Id);
}
} catch (err) {
setError('Failed to fetch Emby sessions. Make sure Emby is running and configured.');
console.error(err);
}
};
const fetchUserDownloads = async (sessionId) => {
setLoading(true);
setError(null);
try {
const response = await axios.get(`/api/dashboard/user-downloads/${sessionId}`);
setCurrentUser(response.data.user);
setDownloads(response.data.downloads);
} catch (err) {
setError('Failed to fetch downloads. Make sure all services are configured.');
console.error(err);
} finally {
setLoading(false);
}
};
const handleSessionChange = (e) => {
const newSessionId = e.target.value;
setSessionId(newSessionId);
if (newSessionId) {
fetchUserDownloads(newSessionId);
}
};
const formatSize = (bytes) => {
if (!bytes) return 'N/A';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
};
return (
<div className="app">
<header className="app-header">
<h1>Media Download Dashboard</h1>
{currentUser && (
<div className="user-info">
<span className="user-label">Current User:</span>
<span className="user-name">{currentUser}</span>
</div>
)}
</header>
<div className="controls">
<label htmlFor="session-select">Select Emby Session:</label>
<select
id="session-select"
value={sessionId}
onChange={handleSessionChange}
className="session-select"
>
<option value="">-- Select Session --</option>
{sessions.map(session => (
<option key={session.Id} value={session.Id}>
{session.UserName} - {session.Client} {session.NowPlayingItem ? `(Playing: ${session.NowPlayingItem.Name})` : '(Idle)'}
</option>
))}
</select>
<button onClick={fetchSessions} className="refresh-btn">Refresh Sessions</button>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{loading && (
<div className="loading">Loading downloads...</div>
)}
{!loading && !error && (
<div className="downloads-container">
<h2>Your Downloads</h2>
{downloads.length === 0 ? (
<div className="no-downloads">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with "user:yourusername" in Sonarr/Radarr.</p>
</div>
) : (
<div className="downloads-list">
{downloads.map((download, index) => (
<div key={index} className={`download-card ${download.type}`}>
{download.coverArt && (
<div className="download-cover">
<img src={download.coverArt} alt={download.movieName || download.seriesName || download.title} />
</div>
)}
<div className="download-info">
<div className="download-header">
<span className={`download-type ${download.type}`}>
{download.type === 'series' ? '📺 Series' : '🎬 Movie'}
</span>
<span className={`download-status ${download.status}`}>
{download.status}
</span>
</div>
<h3 className="download-title">{download.title}</h3>
{download.seriesName && (
<p className="download-series">Series: {download.seriesName}</p>
)}
{download.movieName && (
<p className="download-movie">Movie: {download.movieName}</p>
)}
<div className="download-details">
<div className="detail-item">
<span className="detail-label">Size:</span>
<span className="detail-value">{formatSize(download.size)}</span>
</div>
{download.progress && (
<div className="detail-item">
<span className="detail-label">Progress:</span>
<span className="detail-value">{download.progress}%</span>
</div>
)}
{download.speed && (
<div className="detail-item">
<span className="detail-label">Speed:</span>
<span className="detail-value">{download.speed}</span>
</div>
)}
{download.eta && (
<div className="detail-item">
<span className="detail-label">ETA:</span>
<span className="detail-value">{download.eta}</span>
</div>
)}
{download.completedAt && (
<div className="detail-item">
<span className="detail-label">Completed:</span>
<span className="detail-value">{formatDate(download.completedAt)}</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
<footer className="app-footer">
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
</footer>
</div>
);
}
export default App;
+292
View File
@@ -0,0 +1,292 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from './state.js';
export async function checkAuthentication() {
try {
// Fetch both auth state and a fresh CSRF token in parallel
const [meRes, csrfRes] = await Promise.all([
fetch('/api/auth/me'),
fetch('/api/auth/csrf')
]);
const data = await meRes.json();
const csrfData = await csrfRes.json();
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
if (data.authenticated) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
return { authenticated: true, user: data.user };
} else {
return { authenticated: false };
}
} catch (err) {
console.error('Authentication check failed:', err);
return { authenticated: false };
}
}
export async function handleLogin(username, password, rememberMe) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
state.currentUser = data.user;
state.isAdmin = !!data.user.isAdmin;
// Store CSRF token returned by login for use in subsequent requests
if (data.csrfToken) state.csrfToken = data.csrfToken;
return { success: true, user: data.user };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (err) {
console.error(err);
return { success: false, error: 'Login failed. Please try again.' };
}
}
export async function handleLogout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: state.csrfToken ? { 'X-CSRF-Token': state.csrfToken } : {}
});
state.currentUser = null;
state.csrfToken = null;
return { success: true };
} catch (err) {
console.error('Logout failed:', err);
return { success: false };
}
}
export async function loadHistory(forceRefresh = false) {
try {
const params = new URLSearchParams({ days: state.historyDays });
if (state.showAll) params.set('showAll', 'true');
if (forceRefresh) params.set('_t', Date.now());
const res = await fetch(`/api/history/recent?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return { success: true, history: data.history || [] };
} catch (err) {
console.error('[History] Load error:', err);
return { success: false, error: 'Failed to load history.' };
}
}
export async function handleBlocklistSearch(download) {
try {
const res = await fetch('/api/dashboard/blocklist-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken
},
body: JSON.stringify({
arrQueueId: download.arrQueueId,
arrType: download.arrType,
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentType: download.arrContentType
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return { success: true };
} catch (err) {
console.error('[Blocklist] Error:', err);
throw err;
}
}
export async function loadAppVersion() {
try {
const res = await fetch('/health');
const data = await res.json();
return data.version || null;
} catch (err) {
return null;
}
}
export async function fetchWebhookMetrics() {
try {
const res = await fetch('/api/dashboard/webhook-metrics');
if (!res.ok) return null;
return await res.json();
} catch (err) {
return null;
}
}
export async function fetchWebhookStatus() {
try {
// Fetch metrics in parallel
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (sonarrRes.ok) {
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
sonarrEnabled = !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Sonarr not configured
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (radarrRes.ok) {
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
radarrEnabled = !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Radarr not configured
}
state.webhookMetrics = await metricsPromise;
// Find instance stats
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
return { success: true };
} catch (err) {
console.error('Failed to fetch webhook status:', err);
return { success: false };
}
}
export async function enableSonarrWebhook() {
try {
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function enableRadarrWebhook() {
try {
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testSonarrWebhook() {
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/sonarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(sonarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function testRadarrWebhook() {
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/radarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': state.csrfToken || ''
},
body: JSON.stringify(radarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
return { success: false, error: err.message };
}
}
export async function refreshStatusPanel() {
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
const data = await res.json();
return { success: true, data };
} catch (err) {
console.error('[Status] Error fetching status:', err);
return { success: false, error: err.message };
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Bootstrap - wire all event handlers on DOMContentLoaded
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
import { initDownloadClientFilter } from './ui/filters.js';
import { initHistoryControls } from './ui/history.js';
import { toggleStatusPanel } from './ui/statusPanel.js';
import { initWebhooks } from './ui/webhooks.js';
import { initThemeSwitcher } from './ui/theme.js';
import { initTabs, goHome } from './ui/tabs.js';
import { handleShowAllToggle } from './sse.js';
import { loadAppVersion } from './api.js';
document.addEventListener('DOMContentLoaded', () => {
// Login form
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', handleLogin);
}
// Logout button
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogoutClick);
}
// Show all toggle
const showAllToggle = document.getElementById('show-all-toggle');
if (showAllToggle) {
showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked));
}
// Status panel toggle
const statusToggle = document.getElementById('status-btn');
if (statusToggle) {
statusToggle.addEventListener('click', toggleStatusPanel);
}
// Home button
const homeBtn = document.getElementById('home-btn');
if (homeBtn) {
homeBtn.addEventListener('click', goHome);
}
// Initialize UI components
initThemeSwitcher();
initTabs();
initDownloadClientFilter();
initHistoryControls();
initWebhooks();
// Load app version
loadAppVersion().then(version => {
const versionEl = document.getElementById('app-version');
if (versionEl && version) {
versionEl.textContent = 'v' + version;
}
});
// Check authentication and initialize
checkAuthenticationAndInit();
});
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, SSE_RECONNECT_MS } from './state.js';
import { renderDownloads } from './ui/downloads.js';
import { hideError, hideLoading } from './ui/auth.js';
import { loadHistory } from './ui/history.js';
export function startSSE() {
stopSSE();
const params = state.showAll ? '?showAll=true' : '';
const source = new EventSource('/api/dashboard/stream' + params);
state.sseSource = source;
let firstMessage = true;
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
state.currentUser = data.user;
state.isAdmin = !!data.isAdmin;
state.downloads = data.downloads;
// Store download clients and update filter dropdown
if (data.downloadClients) {
state.downloadClients = data.downloadClients;
// Trigger filter update
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
document.dispatchEvent(filterUpdateEvent);
}
document.getElementById('currentUser').textContent = state.currentUser || '-';
renderDownloads();
hideError();
if (firstMessage) { firstMessage = false; hideLoading(); }
} catch (err) {
console.error('[SSE] Failed to parse message:', err);
}
};
// Listen for history-update events from server
source.addEventListener('history-update', (event) => {
try {
const data = JSON.parse(event.data);
console.log('[SSE] History update received:', data.type);
// Trigger history reload
const historyReloadEvent = new CustomEvent('historyReload');
document.dispatchEvent(historyReloadEvent);
} catch (err) {
console.error('[SSE] Failed to parse history-update message:', err);
}
});
source.onerror = () => {
// EventSource retries automatically; we just log and show a reconnecting indicator
console.warn('[SSE] Connection lost, browser will retry...');
};
console.log('[SSE] Stream connected');
}
export function stopSSE() {
if (state.sseReconnectTimer) { clearTimeout(state.sseReconnectTimer); state.sseReconnectTimer = null; }
if (state.sseSource) {
state.sseSource.close();
state.sseSource = null;
console.log('[SSE] Stream closed');
}
}
export function handleShowAllToggle(checked) {
state.showAll = checked;
// Re-open stream with updated showAll param
startSSE();
// Trigger history reload with updated showAll param
const historyReloadEvent = new CustomEvent('historyReload');
document.dispatchEvent(historyReloadEvent);
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Global state (using objects for mutability across modules)
export const state = {
currentUser: null,
downloads: [],
downloadClients: [], // List of download clients from server (for ordering/filtering)
selectedDownloadClients: [], // Array of selected client IDs for multi-select filter
isAdmin: false,
showAll: false,
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
// History section state
historyDays: 7, // Default value, will be loaded from localStorage
historyRefreshHandle: null,
ignoreAvailable: false, // Default value, will be loaded from localStorage
lastHistoryItems: [], // raw items from last fetch, for re-filtering without a network round-trip
// SSE stream state
sseSource: null,
sseReconnectTimer: null,
// Status panel state
statusRefreshHandle: null,
// Webhooks state
webhookSectionExpanded: false,
webhookLoading: false,
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
webhookMetrics: null
};
// Constants
export const SPLASH_MIN_MS = 1200; // minimum splash display time
export const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
export const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
export const STATUS_REFRESH_MS = 5000;
+176
View File
@@ -0,0 +1,176 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, SPLASH_MIN_MS } from '../state.js';
import { checkAuthentication, handleLogin as apiHandleLogin, handleLogout as apiHandleLogout } from '../api.js';
import { startSSE, stopSSE } from '../sse.js';
import { stopHistoryRefresh, clearHistory, startHistoryRefresh } from './history.js';
import { closeStatusPanel } from './statusPanel.js';
export function fadeOutLogin() {
return new Promise(resolve => {
const login = document.getElementById('login-container');
login.classList.add('fade-out');
login.addEventListener('transitionend', () => {
login.classList.add('hidden');
login.classList.remove('fade-out');
resolve();
}, { once: true });
});
}
export function showSplash() {
const splash = document.getElementById('splash-screen');
splash.classList.remove('hidden');
splash.style.opacity = '1';
splash.classList.remove('fade-out');
}
export function dismissSplash(startTime) {
return new Promise(resolve => {
const elapsed = Date.now() - (startTime || 0);
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
setTimeout(() => {
const splash = document.getElementById('splash-screen');
splash.classList.add('fade-out');
// Fallback: resolve after transition duration + buffer in case
// transitionend never fires (e.g. display was toggled in same frame)
const TRANSITION_MS = 400;
const fallback = setTimeout(() => {
splash.classList.add('hidden');
resolve();
}, TRANSITION_MS + 100);
splash.addEventListener('transitionend', () => {
clearTimeout(fallback);
splash.classList.add('hidden');
resolve();
}, { once: true });
}, remaining);
});
}
export async function checkAuthenticationAndInit() {
const splashStart = Date.now();
try {
const result = await checkAuthentication();
if (result.authenticated) {
showDashboard();
showLoading();
startSSE();
await dismissSplash(splashStart);
} else {
await dismissSplash(splashStart);
showLogin();
}
} catch (err) {
console.error('Authentication check failed:', err);
await dismissSplash(splashStart);
showLogin();
}
}
export async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try {
const result = await apiHandleLogin(username, password, rememberMe);
if (result.success) {
// Fade out login, then show splash while opening SSE stream.
// requestAnimationFrame ensures the browser paints the splash at
// opacity:1 before dismissSplash adds fade-out, so the CSS
// transition fires and transitionend is guaranteed.
await fadeOutLogin();
showSplash();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
showDashboard();
showLoading();
const splashStart = Date.now();
startSSE();
await dismissSplash(splashStart);
} else {
showLoginError(result.error || 'Login failed');
}
} catch (err) {
showLoginError('Login failed. Please try again.');
console.error(err);
}
}
export async function handleLogoutClick() {
try {
stopSSE();
stopHistoryRefresh();
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
await apiHandleLogout();
state.currentUser = null;
clearHistory();
showLogin();
} catch (err) {
console.error('Logout failed:', err);
}
}
export function showLogin() {
document.getElementById('login-container').classList.remove('hidden');
document.getElementById('dashboard-container').classList.add('hidden');
hideLoginError();
}
export function showDashboard() {
document.getElementById('login-container').classList.add('hidden');
document.getElementById('dashboard-container').classList.remove('hidden');
document.getElementById('currentUser').textContent = state.currentUser.name || '-';
// Always start with status panel hidden (guards against stale display value on re-login)
const sp = document.getElementById('status-panel');
sp.classList.add('hidden');
// Also hide webhooks-section to keep them in sync (both show/hide together)
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.classList.add('hidden');
const adminControls = document.getElementById('admin-controls');
if (state.isAdmin) {
adminControls.classList.remove('hidden');
} else {
adminControls.classList.add('hidden');
}
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
// Initialise days input from saved value
const daysInput = document.getElementById('history-days');
if (daysInput) daysInput.value = state.historyDays;
startHistoryRefresh();
}
export function showLoginError(message) {
const errorDiv = document.getElementById('login-error');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
export function hideLoginError() {
const errorDiv = document.getElementById('login-error');
errorDiv.classList.add('hidden');
}
export function showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
export function hideError() {
const errorDiv = document.getElementById('error-message');
errorDiv.classList.add('hidden');
}
export function showLoading() {
const loading = document.getElementById('loading');
loading.classList.remove('hidden');
}
export function hideLoading() {
const loading = document.getElementById('loading');
loading.classList.add('hidden');
}
+507
View File
@@ -0,0 +1,507 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
import { handleBlocklistSearch } from '../api.js';
export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
const fragment = document.createDocumentFragment();
if (showAll && tagBadges && tagBadges.length > 0) {
const unmatched = tagBadges.filter(b => !b.matchedUser);
const matched = tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
fragment.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
fragment.appendChild(badge);
}
} else if (matchedUserTag) {
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = matchedUserTag;
fragment.appendChild(matchedBadge);
}
return fragment;
}
function createClientLogo(download) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
const clientLogo = document.createElement('img');
clientLogo.className = 'download-client-logo';
clientLogo.src = `/images/clients/${download.client}.svg`;
clientLogo.alt = `${download.instanceName || download.client} icon`;
clientLogo.title = download.instanceName || download.client;
clientLogo.onerror = () => {
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
};
clientLogoWrapper.appendChild(clientLogo);
return clientLogoWrapper;
}
export function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
// Filter downloads by selected clients
let filteredDownloads = state.downloads;
if (state.selectedDownloadClients.length > 0) {
// Map indices to client objects, then filter by both client type and instanceId
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
filteredDownloads = state.downloads.filter(d =>
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
);
}
// Sort downloads by client order (matching the order in downloadClients)
if (state.downloadClients.length > 0) {
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
filteredDownloads = [...filteredDownloads].sort((a, b) => {
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
return orderA - orderB;
});
}
if (filteredDownloads.length === 0) {
noDownloads.classList.remove('hidden');
downloadsList.innerHTML = '';
return;
}
noDownloads.classList.add('hidden');
// Get existing cards
const existingCards = new Map();
downloadsList.querySelectorAll('.download-card').forEach(card => {
existingCards.set(card.dataset.id, card);
});
// Track which downloads we've processed
const processedIds = new Set();
filteredDownloads.forEach(download => {
const id = download.title;
processedIds.add(id);
const existingCard = existingCards.get(id);
if (existingCard) {
// Update existing card
updateDownloadCard(existingCard, download);
} else {
// Create new card
const card = createDownloadCard(download);
downloadsList.appendChild(card);
}
});
// Remove cards for downloads that no longer exist
existingCards.forEach((card, id) => {
if (!processedIds.has(id)) {
card.remove();
}
});
}
export function updateDownloadCard(card, download) {
// Remove old header-right container if it exists
const oldRightSide = card.querySelector('.download-header-right');
if (oldRightSide) {
oldRightSide.remove();
}
// Remove old user badges directly in header
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
oldBadges.forEach(badge => badge.remove());
// Remove old client logo from header (old structure)
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
if (oldLogoInHeader) {
oldLogoInHeader.remove();
}
// Remove old client logo from card (new structure) if it exists
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
if (oldLogoInCard) {
oldLogoInCard.remove();
}
// Add new right-side container with user badge only
const header = card.querySelector('.download-header');
if (header && !header.querySelector('.download-header-right')) {
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
rightSide.appendChild(badges);
header.appendChild(rightSide);
}
// Add client logo to card (positioned at bottom right via CSS)
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
card.appendChild(createClientLogo(download));
}
// Update status
const statusEl = card.querySelector('.download-status');
if (statusEl && statusEl.textContent !== download.status) {
statusEl.textContent = download.status;
statusEl.className = `download-status ${download.status}`;
}
// Update progress bar and missing pieces
const progressContainer = card.querySelector('.progress-container');
if (progressContainer && download.progress !== undefined) {
const progressBar = progressContainer.querySelector('.progress-bar');
const progressText = progressContainer.querySelector('.progress-text');
const missingText = progressContainer.querySelector('.missing-text');
if (progressBar) {
const downloaded = progressBar.querySelector('.downloaded');
if (downloaded) {
downloaded.style.width = download.progress + '%';
}
}
if (progressText) {
progressText.textContent = download.progress + '%';
}
if (missingText) {
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
if (missingMb > 0 && totalMb > 0) {
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
} else {
missingText.textContent = '';
}
}
}
// Update speed
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
if (speedEl && download.speed !== undefined) {
speedEl.textContent = formatSpeed(download.speed);
}
// Update ETA
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
if (etaEl && download.eta !== undefined) {
etaEl.textContent = download.eta;
}
// Update qBittorrent-specific fields
if (download.qbittorrent) {
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
if (seedsEl && download.seeds !== undefined) {
seedsEl.textContent = download.seeds;
}
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
if (peersEl && download.peers !== undefined) {
peersEl.textContent = download.peers;
}
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
if (availabilityItem && download.availability !== undefined) {
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
}
}
}
export async function handleBlocklistSearchClick(btn, download) {
console.log('[Blocklist] Clicked, download:', download);
console.log('[Blocklist] Required fields:', {
arrQueueId: download.arrQueueId,
arrType: download.arrType,
arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId,
arrContentType: download.arrContentType,
isAdmin: state.isAdmin,
canBlocklist: download.canBlocklist
});
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
btn.disabled = true;
btn.textContent = '⏳ Working…';
try {
await handleBlocklistSearch(download);
btn.textContent = '✓ Done — searching…';
btn.className = 'blocklist-search-btn success';
} catch (err) {
console.error('[Blocklist] Error:', err);
btn.disabled = false;
btn.textContent = '⛔ Blocklist & Search';
btn.className = 'blocklist-search-btn error';
btn.title = `Failed: ${err.message}`;
setTimeout(() => {
btn.className = 'blocklist-search-btn';
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger an automatic search';
}, 4000);
}
}
export function createDownloadCard(download) {
const card = document.createElement('div');
card.className = `download-card ${download.type}`;
card.dataset.id = download.title;
// Cover art
if (download.coverArt) {
const coverDiv = document.createElement('div');
coverDiv.className = 'download-cover';
const coverImg = document.createElement('img');
// Proxy cover art through the server so the CSP img-src 'self' rule
// is satisfied (external poster URLs would be blocked otherwise).
coverImg.src = download.coverArt
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
: '';
coverImg.alt = download.movieName || download.seriesName || download.title;
coverImg.loading = 'lazy';
coverDiv.appendChild(coverImg);
card.appendChild(coverDiv);
}
// Info wrapper
const infoDiv = document.createElement('div');
infoDiv.className = 'download-info';
const header = document.createElement('div');
header.className = 'download-header';
const type = document.createElement('span');
type.className = `download-type ${download.type}`;
if (download.type === 'series') {
type.textContent = '📺 Series';
} else if (download.type === 'movie') {
type.textContent = '🎬 Movie';
} else if (download.type === 'torrent') {
const instName = download.instanceName ? ` (${download.instanceName})` : '';
type.textContent = `📥 Torrent${instName}`;
} else {
type.textContent = download.type;
}
const status = document.createElement('span');
status.className = `download-status ${download.status}`;
status.textContent = download.status;
header.appendChild(type);
header.appendChild(status);
if (download.importIssues && download.importIssues.length > 0) {
const issueBadge = document.createElement('span');
issueBadge.className = 'import-issue-badge';
issueBadge.textContent = 'Import Pending';
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
header.appendChild(issueBadge);
}
if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) {
const blBtn = document.createElement('button');
blBtn.className = 'blocklist-search-btn';
blBtn.textContent = '⛔ Blocklist & Search';
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download));
header.appendChild(blBtn);
}
// Right side container for user badge only
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
rightSide.appendChild(badges);
header.appendChild(rightSide);
// Add client logo to card (positioned at bottom right via CSS)
if (download.client) {
card.appendChild(createClientLogo(download));
}
const title = document.createElement('h3');
title.className = 'download-title';
title.textContent = download.title;
infoDiv.appendChild(header);
infoDiv.appendChild(title);
if (download.seriesName) {
const series = document.createElement('p');
series.className = 'download-series';
if (state.isAdmin && download.arrLink) {
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
} else {
series.textContent = `Series: ${download.seriesName}`;
}
infoDiv.appendChild(series);
const epEl = formatEpisodeInfo(download.episodes);
if (epEl) infoDiv.appendChild(epEl);
}
if (download.movieName) {
const movie = document.createElement('p');
movie.className = 'download-movie';
if (state.isAdmin && download.arrLink) {
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
} else {
movie.textContent = `Movie: ${download.movieName}`;
}
infoDiv.appendChild(movie);
}
const details = document.createElement('div');
details.className = 'download-details';
const size = createDetailItem('Size', formatSize(download.size));
details.appendChild(size);
if (download.progress !== undefined) {
const progressItem = document.createElement('div');
progressItem.className = 'detail-item progress-item';
progressItem.dataset.label = 'Progress';
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = 'Progress';
const valueDiv = document.createElement('div');
valueDiv.className = 'progress-container';
// Progress bar with segments
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
const downloadedMb = totalMb - missingMb;
const progressPercent = parseFloat(download.progress) || 0;
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
// Downloaded portion (green)
if (progressPercent > 0) {
const downloaded = document.createElement('div');
downloaded.className = 'progress-segment downloaded';
downloaded.style.width = progressPercent + '%';
progressBar.appendChild(downloaded);
}
valueDiv.appendChild(progressBar);
// Text showing percentage
const progressText = document.createElement('span');
progressText.className = 'progress-text';
progressText.textContent = download.progress + '%';
valueDiv.appendChild(progressText);
// Missing pieces text (only for torrent clients like qBittorrent)
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
const missingText = document.createElement('span');
missingText.className = 'missing-text';
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
valueDiv.appendChild(missingText);
}
progressItem.appendChild(labelSpan);
progressItem.appendChild(valueDiv);
details.appendChild(progressItem);
}
if (download.speed && download.speed > 0) {
const speed = createDetailItem('Speed', formatSpeed(download.speed));
details.appendChild(speed);
}
if (download.eta) {
const eta = createDetailItem('ETA', download.eta);
details.appendChild(eta);
}
// qBittorrent-specific fields
if (download.qbittorrent) {
if (download.seeds !== undefined) {
const seeds = createDetailItem('Seeds', download.seeds);
details.appendChild(seeds);
}
if (download.peers !== undefined) {
const peers = createDetailItem('Peers', download.peers);
details.appendChild(peers);
}
if (download.availability !== undefined) {
const availability = createDetailItem('Availability', `${download.availability}%`);
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
details.appendChild(availability);
}
}
if (download.completedAt) {
const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed);
}
if (state.isAdmin && (download.downloadPath || download.targetPath)) {
const pathsDiv = document.createElement('div');
pathsDiv.className = 'download-paths';
if (download.downloadPath) {
const dlPath = document.createElement('div');
dlPath.className = 'path-item';
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
pathsDiv.appendChild(dlPath);
}
if (download.targetPath) {
const tgtPath = document.createElement('div');
tgtPath.className = 'path-item';
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
pathsDiv.appendChild(tgtPath);
}
details.appendChild(pathsDiv);
}
infoDiv.appendChild(details);
card.appendChild(infoDiv);
return card;
}
export function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.dataset.label = label;
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'detail-value';
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
return item;
}
function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { saveDownloadClients } from '../utils/storage.js';
import { renderDownloads } from './downloads.js';
export function initDownloadClientFilter() {
const filterBtn = document.getElementById('download-client-filter-btn');
const filterDropdown = document.getElementById('download-client-filter-dropdown');
const filterClose = document.getElementById('download-client-filter-close');
if (!filterBtn || !filterDropdown) return;
filterBtn.addEventListener('click', (e) => {
e.stopPropagation();
filterDropdown.classList.toggle('open');
});
filterClose.addEventListener('click', () => {
filterDropdown.classList.remove('open');
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!filterDropdown.contains(e.target) && e.target !== filterBtn) {
filterDropdown.classList.remove('open');
}
});
// Listen for download clients updates from SSE
document.addEventListener('downloadClientsUpdated', updateDownloadClientFilter);
// Initial filter update
updateDownloadClientFilter();
}
export function updateDownloadClientFilter() {
const filterList = document.getElementById('download-client-filter-list');
if (!filterList) return;
filterList.innerHTML = '';
state.downloadClients.forEach((client, index) => {
const item = document.createElement('div');
item.className = 'filter-item';
item.dataset.index = index;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `client-${index}`;
checkbox.checked = state.selectedDownloadClients.includes(index);
checkbox.addEventListener('change', () => toggleClientSelection(index));
const label = document.createElement('label');
label.htmlFor = `client-${index}`;
label.textContent = client.name || `${client.type} (${client.id})`;
item.appendChild(checkbox);
item.appendChild(label);
filterList.appendChild(item);
});
updateSelectedCountDisplay();
}
export function toggleClientSelection(index) {
const idx = state.selectedDownloadClients.indexOf(index);
if (idx > -1) {
state.selectedDownloadClients.splice(idx, 1);
} else {
state.selectedDownloadClients.push(index);
}
saveDownloadClients(state.selectedDownloadClients);
updateSelectedCountDisplay();
renderDownloads();
}
export function updateSelectedCountDisplay() {
const countDisplay = document.getElementById('download-client-filter-count');
if (!countDisplay) return;
if (state.selectedDownloadClients.length === 0) {
countDisplay.textContent = 'All';
} else {
countDisplay.textContent = state.selectedDownloadClients.length;
}
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, HISTORY_REFRESH_MS } from '../state.js';
import { loadHistory as apiLoadHistory } from '../api.js';
import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
import { renderTagBadges } from './downloads.js';
export function initHistoryControls() {
const daysInput = document.getElementById('history-days');
const refreshBtn = document.getElementById('history-refresh-btn');
const ignoreToggle = document.getElementById('ignore-available-toggle');
if (daysInput) {
daysInput.addEventListener('change', () => {
const v = parseInt(daysInput.value, 10);
if (v > 0 && v <= 90) {
historyDays = v;
saveHistoryDays(v);
loadHistory(true);
}
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadHistory(true));
}
if (ignoreToggle) {
ignoreToggle.checked = state.ignoreAvailable;
ignoreToggle.addEventListener('change', () => {
state.ignoreAvailable = ignoreToggle.checked;
saveIgnoreAvailable(state.ignoreAvailable);
renderHistory(state.lastHistoryItems);
});
}
// Listen for history reload events from other modules
document.addEventListener('historyReload', () => {
loadHistory(true);
});
}
export function startHistoryRefresh() {
stopHistoryRefresh();
state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
}
export function stopHistoryRefresh() {
if (state.historyRefreshHandle) {
clearInterval(state.historyRefreshHandle);
state.historyRefreshHandle = null;
}
}
export function clearHistory() {
state.lastHistoryItems = [];
document.getElementById('history-list').innerHTML = '';
document.getElementById('no-history').classList.add('hidden');
document.getElementById('history-error').classList.add('hidden');
}
export async function loadHistory(forceRefresh = false) {
const listEl = document.getElementById('history-list');
const loadingEl = document.getElementById('history-loading');
const errorEl = document.getElementById('history-error');
const noHistoryEl = document.getElementById('no-history');
loadingEl.classList.remove('hidden');
errorEl.classList.add('hidden');
noHistoryEl.classList.add('hidden');
try {
const result = await apiLoadHistory(forceRefresh);
loadingEl.classList.add('hidden');
if (result.success) {
state.lastHistoryItems = result.history;
renderHistory(state.lastHistoryItems);
} else {
errorEl.textContent = result.error || 'Failed to load history.';
errorEl.classList.remove('hidden');
}
} catch (err) {
loadingEl.classList.add('hidden');
errorEl.textContent = 'Failed to load history.';
errorEl.classList.remove('hidden');
console.error('[History] Load error:', err);
}
}
export function renderHistory(items) {
const listEl = document.getElementById('history-list');
const noHistoryEl = document.getElementById('no-history');
listEl.innerHTML = '';
const visible = state.ignoreAvailable
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
: items;
if (!visible.length) {
noHistoryEl.classList.remove('hidden');
return;
}
noHistoryEl.classList.add('hidden');
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
}
export function createHistoryCard(item) {
const card = document.createElement('div');
card.className = `history-card ${item.type} ${item.outcome}`;
if (item.coverArt) {
const coverDiv = document.createElement('div');
coverDiv.className = 'history-cover';
const img = document.createElement('img');
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
img.alt = item.movieName || item.seriesName || item.title;
img.loading = 'lazy';
coverDiv.appendChild(img);
card.appendChild(coverDiv);
}
const info = document.createElement('div');
info.className = 'history-info';
// Header row: type badge + outcome badge
const header = document.createElement('div');
header.className = 'history-card-header';
const typeBadge = document.createElement('span');
typeBadge.className = `history-type-badge ${item.type}`;
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
header.appendChild(typeBadge);
const outcomeBadge = document.createElement('span');
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
header.appendChild(outcomeBadge);
if (item.availableForUpgrade) {
const upgradeBadge = document.createElement('span');
upgradeBadge.className = 'history-upgrade-badge';
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
upgradeBadge.textContent = '⬆ Available';
header.appendChild(upgradeBadge);
}
if (item.instanceName) {
const instBadge = document.createElement('span');
instBadge.className = 'history-instance-badge';
instBadge.textContent = item.instanceName;
header.appendChild(instBadge);
}
const badges = renderTagBadges(item.tagBadges, state.showAll, item.matchedUserTag);
header.appendChild(badges);
info.appendChild(header);
// Title
const title = document.createElement('h3');
title.className = 'history-title';
title.textContent = item.title;
info.appendChild(title);
// Series/movie name with optional arr link
if (item.seriesName) {
const p = document.createElement('p');
p.className = 'history-media-name';
if (state.isAdmin && item.arrLink) {
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
} else {
p.textContent = 'Series: ' + item.seriesName;
}
info.appendChild(p);
const epEl = formatEpisodeInfo(item.episodes);
if (epEl) info.appendChild(epEl);
}
if (item.movieName) {
const p = document.createElement('p');
p.className = 'history-media-name';
if (state.isAdmin && item.arrLink) {
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
} else {
p.textContent = 'Movie: ' + item.movieName;
}
info.appendChild(p);
}
// Detail pills
const details = document.createElement('div');
details.className = 'history-details';
if (item.completedAt) {
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
}
if (item.quality) {
details.appendChild(createDetailItem('Quality', item.quality));
}
// Failed imports: show failure message
if (item.outcome === 'failed' && item.failureMessage) {
const failItem = document.createElement('div');
failItem.className = 'history-failure-message';
failItem.textContent = item.failureMessage;
details.appendChild(failItem);
}
info.appendChild(details);
card.appendChild(info);
return card;
}
function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.dataset.label = label;
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'detail-value';
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
return item;
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state, STATUS_REFRESH_MS } from '../state.js';
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
import { fetchWebhookStatus } from './webhooks.js';
export async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
const webhooksSection = document.getElementById('webhooks-section');
if (!panel.classList.contains('hidden')) {
// Close both panels (webhooks is a sibling, hide it too)
panel.classList.add('hidden');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
return;
}
// Open status panel and webhooks section (siblings)
panel.classList.remove('hidden');
// Show webhooks section for admin users (collapsed by default)
if (webhooksSection && state.isAdmin) {
webhooksSection.classList.remove('hidden');
state.webhookSectionExpanded = false;
document.getElementById('webhooks-content').classList.add('hidden');
document.getElementById('webhooks-toggle').classList.remove('expanded');
await fetchWebhookStatus();
} else if (webhooksSection) {
webhooksSection.classList.add('hidden');
}
refreshStatusPanel();
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
}
export function closeStatusPanel() {
document.getElementById('status-panel').classList.add('hidden');
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.classList.add('hidden');
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
}
export async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
const contentDiv = document.getElementById('status-content');
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
if (!panel || panel.classList.contains('hidden')) return;
console.log('[Status] Refreshing status panel...');
try {
const result = await apiRefreshStatusPanel();
if (result.success) {
console.log('[Status] Got status data, rendering...');
renderStatusPanel(result.data, panel);
} else {
console.error('[Status] API returned error:', result.error);
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + result.error + '</p>';
}
}
} catch (err) {
console.error('[Status] Error fetching status:', err);
// Don't overwrite panel on transient error during auto-refresh
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
}
}
}
export function renderStatusPanel(data, panel) {
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
const secs = s.uptimeSeconds % 60;
const uptime = `${hrs}h ${mins}m ${secs}s`;
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
let html = `
<div class="status-header">
<h3>Server Status</h3>
<button class="status-close" id="status-close-btn">&times;</button>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-card-title">Server</div>
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
</div>
<div class="status-card">
<div class="status-card-title">Data Refresh</div>`;
const pollIntervalMs = data.polling.intervalMs;
const clients = data.clients || [];
const sseClients = clients.filter(c => c.type === 'sse');
if (data.polling.enabled) {
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
} else {
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
}
const mode = sseClients.length > 0
? `<span class="status-fg-badge">SSE push</span>`
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
for (const c of sseClients) {
const age = Math.round((Date.now() - c.connectedAt) / 1000);
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
}
html += `</div>`;
// Webhook metrics card (admin only)
if (state.isAdmin && data.webhooks) {
const wh = data.webhooks;
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
const radarrEvents = wh.radarr?.eventsReceived || 0;
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
const radarrPolls = wh.radarr?.pollsSkipped || 0;
html += `
<div class="status-card">
<div class="status-card-title">Webhooks</div>
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
</div>`;
}
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
<div class="status-timings">`;
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
for (const t of lp.tasks) {
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
html += `
<div class="timing-row">
<span class="timing-label">${escapeHtml(t.label)}</span>
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
<span class="timing-value">${t.ms}ms</span>
</div>`;
}
html += `</div></div>`;
}
// Cache table
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
<table class="status-table">
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>`;
for (const e of data.cache.entries) {
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
const items = e.itemCount !== null ? e.itemCount : '—';
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
}
html += `</tbody></table></div></div>`;
// Render into status-content div, not the whole panel (preserves webhooks section)
const contentDiv = document.getElementById('status-content');
const panelCheck = document.getElementById('status-panel');
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
if (panelCheck) {
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
}
if (contentDiv) {
contentDiv.innerHTML = html;
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
} else {
console.error('[Status] contentDiv not found!');
}
// Wire close button — addEventListener avoids CSP inline handler restrictions
const closeBtn = document.getElementById('status-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
el.style.width = el.dataset.w + '%';
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
import { loadHistory } from './history.js';
export function initTabs() {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const historyTab = document.querySelector('[data-tab="history"]');
if (!downloadsTab || !historyTab) return;
// Load saved tab
const savedTab = getActiveTab();
if (savedTab === 'history') {
activateTab('history');
} else {
activateTab('downloads');
}
downloadsTab.addEventListener('click', () => activateTab('downloads'));
historyTab.addEventListener('click', () => activateTab('history'));
}
export function activateTab(tab) {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const historyTab = document.querySelector('[data-tab="history"]');
const downloadsSection = document.getElementById('tab-downloads');
const historySection = document.getElementById('tab-history');
if (tab === 'downloads') {
downloadsTab.classList.add('active');
historyTab.classList.remove('active');
downloadsSection.classList.remove('hidden');
historySection.classList.add('hidden');
saveActiveTab('downloads');
} else if (tab === 'history') {
historyTab.classList.add('active');
downloadsTab.classList.remove('active');
historySection.classList.remove('hidden');
downloadsSection.classList.add('hidden');
saveActiveTab('history');
loadHistory();
}
}
export function goHome() {
activateTab('downloads');
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { getTheme, saveTheme } from '../utils/storage.js';
// Apply saved theme immediately on load
(function applyTheme() {
const theme = getTheme();
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
})();
export function initThemeSwitcher() {
const themeToggle = document.getElementById('theme-toggle');
if (!themeToggle) return;
themeToggle.addEventListener('click', () => {
const currentTheme = getTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
saveTheme(theme);
}
+213
View File
@@ -0,0 +1,213 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js';
import { formatTimeAgo } from '../utils/format.js';
export function initWebhooks() {
const webhooksSection = document.getElementById('webhooks-section');
if (!webhooksSection) return;
// Note: visibility is controlled by showDashboard() based on isAdmin
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
}
export function toggleWebhookSection() {
state.webhookSectionExpanded = !state.webhookSectionExpanded;
const content = document.getElementById('webhooks-content');
const toggle = document.getElementById('webhooks-toggle');
if (state.webhookSectionExpanded) {
content.classList.remove('hidden');
} else {
content.classList.add('hidden');
}
toggle.classList.toggle('expanded', state.webhookSectionExpanded);
if (state.webhookSectionExpanded) {
fetchWebhookStatus();
}
}
export async function fetchWebhookStatus() {
const loadingEl = document.getElementById('webhook-loading');
loadingEl.classList.remove('hidden');
try {
const result = await apiFetchWebhookStatus();
if (result.success) {
renderWebhookStatus();
}
} catch (err) {
console.error('Failed to fetch webhook status:', err);
} finally {
loadingEl.classList.add('hidden');
}
}
export function renderWebhookStatus() {
// Sonarr
const sonarrStatus = document.getElementById('sonarr-status');
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
const sonarrTriggers = document.getElementById('sonarr-triggers');
const sonarrStats = document.getElementById('sonarr-stats');
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
if (sonarrWebhook.enabled) {
sonarrEnableBtn.classList.add('hidden');
sonarrTestBtn.classList.remove('hidden');
sonarrTriggers.classList.remove('hidden');
} else {
sonarrEnableBtn.classList.remove('hidden');
sonarrTestBtn.classList.add('hidden');
sonarrTriggers.classList.add('hidden');
}
if (sonarrWebhook.enabled) {
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (sonarrWebhook.stats) {
sonarrStats.classList.remove('hidden');
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
} else {
sonarrStats.classList.add('hidden');
}
// Radarr
const radarrStatus = document.getElementById('radarr-status');
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
const radarrTestBtn = document.getElementById('test-radarr-webhook');
const radarrTriggers = document.getElementById('radarr-triggers');
const radarrStats = document.getElementById('radarr-stats');
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
if (radarrWebhook.enabled) {
radarrEnableBtn.classList.add('hidden');
radarrTestBtn.classList.remove('hidden');
radarrTriggers.classList.remove('hidden');
} else {
radarrEnableBtn.classList.remove('hidden');
radarrTestBtn.classList.add('hidden');
radarrTriggers.classList.add('hidden');
}
if (radarrWebhook.enabled) {
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (radarrWebhook.stats) {
radarrStats.classList.remove('hidden');
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
} else {
radarrStats.classList.add('hidden');
}
}
export async function enableSonarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableSonarrWebhook();
if (!result.success) {
console.error('Failed to enable Sonarr webhook:', result.error);
alert('Failed to enable Sonarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
alert('Failed to enable Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function enableRadarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableRadarrWebhook();
if (!result.success) {
console.error('Failed to enable Radarr webhook:', result.error);
alert('Failed to enable Radarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
alert('Failed to enable Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testSonarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestSonarrWebhook();
if (result.success) {
alert('Sonarr webhook test sent successfully!');
} else {
console.error('Failed to test Sonarr webhook:', result.error);
alert('Failed to test Sonarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
alert('Failed to test Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testRadarrWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestRadarrWebhook();
if (result.success) {
alert('Radarr webhook test sent successfully!');
} else {
console.error('Failed to test Radarr webhook:', result.error);
alert('Failed to test Radarr webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
alert('Failed to test Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export function setWebhookLoading(loading) {
state.webhookLoading = loading;
document.getElementById('enable-sonarr-webhook').disabled = loading;
document.getElementById('enable-radarr-webhook').disabled = loading;
document.getElementById('test-sonarr-webhook').disabled = loading;
document.getElementById('test-radarr-webhook').disabled = loading;
const loadingEl = document.getElementById('webhook-loading');
if (loading) {
loadingEl.classList.remove('hidden');
} else {
loadingEl.classList.add('hidden');
}
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
export function formatSize(size) {
if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is
if (typeof size === 'string') {
return size;
}
// If it's a number (bytes), format it
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(1024));
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export function formatSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
export function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
export function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
export function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Build an episode-info element for series downloads/history.
// Single episode: "S01E05 — Episode Title"
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
// Returns null if no episode data.
export function formatEpisodeInfo(episodes) {
if (!episodes || episodes.length === 0) return null;
const el = document.createElement('p');
el.className = 'episode-info';
if (episodes.length === 1) {
const ep = episodes[0];
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
} else {
el.textContent = 'Multiple episodes';
el.classList.add('multi-episode');
const lines = episodes.map(ep => {
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
return ep.title ? code + ' \u2014 ' + ep.title : code;
});
el.setAttribute('data-tooltip', lines.join('\n'));
}
return el;
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
// Migration from old single-select to new multi-select format
(function migrateDownloadClientFilter() {
const oldSelection = localStorage.getItem('sofarr-download-client');
if (oldSelection && oldSelection !== 'all') {
try {
state.selectedDownloadClients = [oldSelection];
localStorage.setItem('sofarr-download-clients', JSON.stringify(state.selectedDownloadClients));
localStorage.removeItem('sofarr-download-client');
} catch (e) {
console.error('[Migration] Failed to migrate download client filter:', e);
}
} else {
try {
const newSelection = localStorage.getItem('sofarr-download-clients');
state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
} catch (e) {
console.error('[Migration] Failed to load download client filter:', e);
state.selectedDownloadClients = [];
}
}
})();
// Load history days from localStorage
(function loadHistorySettings() {
try {
const savedDays = localStorage.getItem('sofarr-history-days');
if (savedDays) {
state.historyDays = parseInt(savedDays, 10) || 7;
}
} catch (e) {
console.error('[Storage] Failed to load history days:', e);
}
})();
// Load ignore available setting from localStorage
(function loadIgnoreAvailable() {
try {
const saved = localStorage.getItem('sofarr-ignore-available');
state.ignoreAvailable = saved === 'true';
} catch (e) {
console.error('[Storage] Failed to load ignore available:', e);
}
})();
// Export helper functions for localStorage operations
export function saveHistoryDays(days) {
localStorage.setItem('sofarr-history-days', days);
}
export function saveIgnoreAvailable(value) {
localStorage.setItem('sofarr-ignore-available', value);
}
export function saveDownloadClients(clients) {
localStorage.setItem('sofarr-download-clients', JSON.stringify(clients));
}
export function getTheme() {
return localStorage.getItem('sofarr-theme') || 'light';
}
export function saveTheme(theme) {
localStorage.setItem('sofarr-theme', theme);
}
export function getActiveTab() {
return localStorage.getItem('sofarr-active-tab') || 'downloads';
}
export function saveActiveTab(tab) {
localStorage.setItem('sofarr-active-tab', tab);
}
+15 -2
View File
@@ -1,8 +1,21 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: '../public',
emptyOutDir: false,
rollupOptions: {
input: {
main: './src/main.js'
},
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]'
}
}
},
server: {
port: 5173,
proxy: {
+5 -1
View File
@@ -44,13 +44,17 @@ services:
volumes:
# Persistent volume for token store and log file
- sofarr-data:/app/data
# Mount code for development (comment out in production)
- ./server:/app/server
- ./public:/app/public
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
# - /path/to/your/server.crt:/app/certs/server.crt:ro
# - /path/to/your/server.key:/app/certs/server.key:ro
# Run as the built-in non-root 'node' user (UID/GID 1000)
user: "1000:1000"
# Read-only root filesystem; only the data volume is writable
read_only: true
# Comment out for development when mounting code volumes
# read_only: true
tmpfs:
- /tmp # Node.js needs a writable /tmp
security_opt:
+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.*
-1474
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",
"version": "1.1.2",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.1.2",
"version": "1.3.1",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
@@ -15,7 +15,8 @@
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0",
"jsdom": "^29.1.1"
"jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
@@ -3127,6 +3128,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -3998,12 +4005,35 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"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": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.2.2",
"version": "1.6.0",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
@@ -22,7 +22,8 @@
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0",
"jsdom": "^29.1.1"
"jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
+28 -1045
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m256.1 2.6 142 217.2c91 139.2-4.7 289.6-142.1 289.6S22.8 358.9 113.9 219.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#4c90e8;stroke:#094491;stroke-width:3.1904"/><path d="M306.7 255.2c-77.6-31.7-118.4 49.2-105.4 87C229.5 424.4 335.8 445 414.2 308c0 0 .8 16.5 1.7 24.4 10.5 99.9-73.5 163.4-158.6 162.2s-111.1-34.3-136.2-72.3C80.4 360.6 90.5 260 145.2 212.9c62.5-51.6 131.2-24 161.5 42.3" style="fill-rule:evenodd;clip-rule:evenodd;fill:#094491"/><path d="M257.9 225.3c-87 2.1-103.5 102.3-79.4 145.3 33.5 59.7 84.3 71.2 153.8 49.1-39.7 43.2-121.2 54.6-176.5-7.1-38.1-42.4-41.4-101.6-15-151.1 26.5-49.4 79.2-63.2 117.1-36.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#83b8f9"/></svg>

After

Width:  |  Height:  |  Size: 786 B

+1
View File
@@ -0,0 +1 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="348.2829" x2="782.05951" y1="0" y2="786.48322"><stop offset="0" stop-color="#72b4f5"/><stop offset="1" stop-color="#356ebf"/></linearGradient><g fill="none" fill-rule="evenodd" transform="matrix(.97656268 0 0 .9765624 11.999908 12.000051)"><circle cx="512" cy="512" fill="url(#a)" r="496" stroke="#daefff" stroke-width="32"/><path d="m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z" fill="#fff"/><path d="m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z" fill="#c8e8ff"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linejoin="round" stroke-width="74" d="M200.4 39.3h598.1v437.8h161l-460.1 483L39.4 477h161z"/><path fill="#ffb300" fill-rule="evenodd" d="M200.4 39.3h598.1v437.8h161l-460.1 483-460-483h161z"/><path fill="#ffca28" fill-rule="evenodd" d="M499.4 960.2 201.1 39.4h596.7z"/><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linecap="round" stroke-linejoin="round" stroke-width="74" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-97.8h46v46h-46zm192.1 97.8v-344h100.1v97.4h146.1v246.6zm100.1-195.2h46v143.4h-46z"/><path fill="#0f0f0f" fill-rule="evenodd" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-51.8h46v-46h-46zm192.1 51.9v-344h100.1V597h146.1v246.6zm100.1-51.9h46V648.4h-46z"/></svg>

After

Width:  |  Height:  |  Size: 966 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

+100 -14
View File
@@ -18,7 +18,7 @@
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;">
<div id="login-container" class="login-container hidden">
<div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
@@ -39,21 +39,21 @@
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
<div id="login-error" class="error-message hidden"></div>
</div>
</div>
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<div id="dashboard-container" class="dashboard-container hidden">
<header class="app-header">
<h1><a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="title-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
<div class="header-controls">
<div class="theme-switcher">
<button class="theme-btn active" data-theme="light">Light</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button>
</div>
<div id="admin-controls" class="admin-controls" style="display: none;">
<div id="admin-controls" class="admin-controls hidden">
<label class="toggle-label">
<input type="checkbox" id="show-all-toggle">
<span>Show all users</span>
@@ -68,11 +68,73 @@
</div>
</header>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<div id="status-panel" class="status-panel hidden">
<!-- Status content gets rendered here -->
<div id="status-content"><p class="status-loading">Loading status...</p></div>
</div>
<div id="error-message" class="error-message" style="display: none;"></div>
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
<div class="webhooks-section hidden" id="webhooks-section">
<div class="webhooks-header" id="webhooks-header">
<h2>⚡ Webhooks Configuration</h2>
<span class="webhooks-toggle" id="webhooks-toggle"></span>
</div>
<div class="webhooks-content hidden" id="webhooks-content">
<div id="webhook-loading" class="webhook-loading hidden">Loading webhook status...</div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
<!-- Sonarr Webhook -->
<div class="webhook-instance">
<h3>Sonarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
<button id="enable-sonarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-sonarr-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="sonarr-triggers">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats hidden" id="sonarr-stats">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
</div>
</div>
</div>
<!-- Radarr Webhook -->
<div class="webhook-instance">
<h3>Radarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="radarr-status">○ Disabled</span>
<button id="enable-radarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-radarr-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="radarr-triggers">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats hidden" id="radarr-stats">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
</div>
</div>
</div>
</div>
</div>
<div id="error-message" class="error-message hidden"></div>
<div id="loading" class="loading hidden">Loading downloads...</div>
<div class="main-tabs">
<div class="tab-bar">
@@ -82,7 +144,27 @@
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div id="no-downloads" class="no-downloads" style="display: none;">
<div class="downloads-header">
<div class="downloads-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label>
<div class="download-client-filter" id="download-client-filter">
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
<span id="download-client-selected-text">All clients</span>
<span class="dropdown-arrow"></span>
</button>
<div class="download-client-dropdown" id="download-client-dropdown">
<div class="download-client-dropdown-header">
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
</div>
<div class="download-client-options" id="download-client-options">
<!-- Options will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<div id="no-downloads" class="no-downloads hidden">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
@@ -90,7 +172,7 @@
</div>
</div>
<div class="tab-panel" id="tab-history" style="display: none;">
<div class="tab-panel hidden" id="tab-history">
<div class="history-container" id="history-container">
<div class="history-header">
<div class="history-controls">
@@ -98,11 +180,15 @@
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span>
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">&#8635;</button>
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
<input type="checkbox" id="ignore-available-toggle">
<span>Hide upgrade failures</span>
</label>
</div>
</div>
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
<div id="history-error" class="history-error" style="display: none;"></div>
<div id="no-history" class="no-history" style="display: none;">
<div id="history-loading" class="history-loading hidden">Loading history...</div>
<div id="history-error" class="history-error hidden"></div>
<div id="no-history" class="no-history hidden">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
@@ -112,7 +198,7 @@
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
<p class="app-version" id="app-version"></p>
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
</footer>
</div>
</div>
+585 -8
View File
@@ -1,3 +1,8 @@
/* ===== Utility Classes ===== */
.hidden {
display: none !important;
}
/* ===== Splash Screen ===== */
.splash-screen {
position: fixed;
@@ -36,6 +41,7 @@
--bg-gradient-end: #d4dee8;
/* Surfaces */
--background: #f5f7f9;
--surface: #ffffff;
--surface-alt: #f0f4f7;
@@ -98,6 +104,7 @@
[data-theme="dark"] {
--bg-gradient-start: #1a1a2e;
--bg-gradient-end: #16213e;
--background: #161622;
--surface: #1e1e2f;
--surface-alt: #2a2a3d;
--text-primary: #e0e0e0;
@@ -136,6 +143,7 @@
[data-theme="mono"] {
--bg-gradient-start: #222222;
--bg-gradient-end: #333333;
--background: #141414;
--surface: #1a1a1a;
--surface-alt: #252525;
--text-primary: #d0d0d0;
@@ -370,6 +378,7 @@ body {
align-items: flex-start;
transition: box-shadow 0.2s, background 0.3s;
background: var(--surface);
position: relative;
}
.download-card:hover {
@@ -659,6 +668,212 @@ body {
padding: 0;
}
/* Downloads header and controls */
.downloads-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.downloads-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.download-client-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.download-client-select {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.download-client-select:focus {
outline: none;
border-color: var(--accent);
}
/* Multi-select dropdown container */
.download-client-filter {
position: relative;
display: inline-block;
}
.download-client-dropdown-btn {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background 0.15s, border-color 0.15s;
}
.download-client-dropdown-btn:hover {
background: var(--hover-bg);
}
.download-client-dropdown-btn:focus {
outline: none;
border-color: var(--accent);
}
.download-client-dropdown-btn .dropdown-arrow {
font-size: 0.75rem;
transition: transform 0.2s;
}
.download-client-dropdown-btn.open .dropdown-arrow {
transform: rotate(180deg);
}
.download-client-count {
background: var(--accent);
color: white;
padding: 1px 6px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
/* Dropdown panel */
.download-client-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: 300px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.download-client-dropdown.open {
display: block;
}
/* Dropdown header with Select All/Deselect All buttons */
.download-client-dropdown-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.download-client-dropdown-btn-small {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface-alt);
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.download-client-dropdown-btn-small:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
/* Client option row */
.download-client-option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background 0.15s;
}
.download-client-option:hover {
background: var(--hover-bg);
}
.download-client-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.download-client-option-label {
flex: 1;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
}
.download-client-type {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--surface-alt);
padding: 1px 6px;
border-radius: 3px;
}
/* Empty state */
.download-client-empty {
padding: 12px;
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Client icon */
.download-client-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
}
.download-client-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.download-client-icon.fallback {
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-alt);
border-radius: 3px;
color: var(--text-primary);
}
.history-header {
display: flex;
align-items: center;
@@ -714,6 +929,41 @@ body {
color: var(--text-primary);
}
.history-toggle-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
margin-left: 4px;
position: relative;
}
.history-toggle-label[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 20;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 0.75rem;
white-space: pre-line;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 280px;
pointer-events: none;
}
.history-toggle-label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent, #2980b9);
}
.history-loading,
.history-error,
.no-history {
@@ -779,7 +1029,8 @@ body {
.history-type-badge,
.history-outcome-badge,
.history-instance-badge {
.history-instance-badge,
.history-upgrade-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 7px;
@@ -787,6 +1038,12 @@ body {
white-space: nowrap;
}
.history-upgrade-badge {
background: #e67e22;
color: #fff;
cursor: default;
}
.history-type-badge.series {
background: var(--badge-series-bg, #2980b9);
color: #fff;
@@ -870,6 +1127,15 @@ body {
font-size: 0.72rem;
opacity: 0.5;
margin-top: 4px;
color: inherit;
text-decoration: none;
display: inline-block;
}
.app-version:hover {
opacity: 0.8;
text-decoration: underline;
text-underline-offset: 2px;
}
.title-link {
@@ -1086,20 +1352,54 @@ body {
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #424242;
color: #fff;
padding: 8px 12px;
z-index: 20;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.7rem;
padding: 8px 10px;
font-size: 0.75rem;
font-weight: 400;
white-space: pre-line;
max-width: 320px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
line-height: 1.4;
pointer-events: none;
}
.blocklist-search-btn {
font-size: 0.68rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--error, #e74c3c);
background: transparent;
color: var(--error, #e74c3c);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.blocklist-search-btn:hover:not(:disabled) {
background: var(--error, #e74c3c);
color: #fff;
}
.blocklist-search-btn:disabled {
opacity: 0.6;
cursor: default;
}
.blocklist-search-btn.success {
border-color: var(--success, #27ae60);
color: var(--success, #27ae60);
}
.blocklist-search-btn.error {
background: var(--error, #e74c3c);
color: #fff;
}
.download-user-badge {
padding: 2px 8px;
border-radius: 10px;
@@ -1108,7 +1408,6 @@ body {
text-transform: capitalize;
background: var(--accent-light);
color: var(--accent);
margin-left: auto;
white-space: nowrap;
}
@@ -1118,6 +1417,52 @@ body {
margin-left: 0;
}
/* Download client logo in card */
.download-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-left: auto;
}
.download-client-logo-wrapper {
width: 20px;
height: 20px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Card-specific logo wrapper positioned at bottom right */
.download-card-logo-wrapper {
width: 32px;
height: 32px;
position: absolute;
bottom: 8px;
right: 8px;
}
.download-client-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.download-client-logo-wrapper.fallback {
font-size: 10px;
font-weight: bold;
background: var(--surface-alt);
border-radius: 2px;
color: var(--text-primary);
}
.download-card-logo-wrapper.fallback {
font-size: 20px;
border-radius: 4px;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
@@ -1147,6 +1492,22 @@ body {
box-shadow: 0 2px 4px var(--shadow);
}
#status-content {
min-height: 150px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--background);
padding: 10px;
margin-bottom: 16px;
}
.status-loading {
color: var(--text-muted);
font-style: italic;
text-align: center;
padding: 20px;
}
.status-header {
display: flex;
justify-content: space-between;
@@ -1431,3 +1792,219 @@ body {
font-size: 0.7rem;
}
}
/* ===== Webhooks Configuration ===== */
.webhooks-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--shadow);
overflow: hidden;
}
.webhooks-header {
padding: 16px 20px;
background: var(--surface-alt);
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.webhooks-header:hover {
background: var(--border);
}
.webhooks-header h2 {
color: var(--text-primary);
font-size: 1.1rem;
margin: 0;
font-weight: 600;
}
/* Webhooks metrics styling to match status cards */
.webhook-stats {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-top: 12px;
}
.webhooks-toggle {
font-size: 1rem;
color: var(--text-muted);
transition: transform 0.3s;
}
.webhooks-toggle.expanded {
transform: rotate(180deg);
}
.webhooks-content {
padding: 20px;
}
.webhook-loading {
text-align: center;
padding: 20px;
color: var(--text-muted);
}
.webhook-instance {
padding: 20px 0;
border-bottom: 1px solid var(--border);
}
.webhook-instance:last-child {
border-bottom: none;
padding-bottom: 0;
}
.webhook-instance h3 {
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 12px;
font-weight: 600;
}
.webhook-status {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.status-indicator {
font-size: 0.95rem;
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background: var(--surface-alt);
color: var(--text-muted);
}
.status-indicator.enabled {
background: var(--success-bg);
color: var(--success);
}
.status-indicator.disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
.enable-webhook-btn {
padding: 6px 14px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.2s;
}
.enable-webhook-btn:hover {
background: var(--accent-hover);
}
.enable-webhook-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.test-webhook-btn {
padding: 6px 14px;
background: var(--info);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.2s;
}
.test-webhook-btn:hover {
background: var(--info-hover, var(--info));
filter: brightness(0.9);
}
.test-webhook-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.webhook-triggers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
padding: 12px;
background: var(--surface-alt);
border-radius: 8px;
margin-top: 8px;
}
.trigger-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
}
.trigger-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.trigger-value {
font-weight: 600;
font-size: 1rem;
}
.trigger-value.active {
color: var(--success);
}
.trigger-value.inactive {
color: var(--text-muted);
}
.webhook-stats-title {
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.webhook-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
}
.webhook-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.webhook-stat-label {
color: var(--text-muted);
font-size: 0.75rem;
}
.webhook-stat-value {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
}
+5
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Express application factory — imported by both server/index.js (production)
* and the test suite. Keeping app creation separate from app.listen() means
@@ -16,8 +17,10 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
@@ -93,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);
@@ -101,6 +105,7 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
// Global error handler
+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;
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Radarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingRadarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'radarr';
}
/**
* Get tags from Radarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Radarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeMovie: true, page, pageSize: 1000 }
});
responseData = response.data;
} catch (error) {
console.error(`Radarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
page <= 50
);
return {
...responseData,
records: allRecords
};
}
/**
* Get history from Radarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=100] - Number of records to fetch per page
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeMovie=true] - Include movie data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
maxPages = 1,
sortKey,
sortDir,
includeMovie = true,
startDate
} = options;
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
const params = {
page,
pageSize,
includeMovie
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
try {
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
responseData = response.data;
} catch (error) {
console.error(`Radarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
page <= maxPages
);
return {
...responseData,
records: allRecords
};
}
}
module.exports = PollingRadarrRetriever;
+137
View File
@@ -0,0 +1,137 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const ArrRetriever = require('./ArrRetriever');
const { logToFile } = require('../utils/logger');
/**
* Polling-based Sonarr data retriever.
* Implements the ArrRetriever interface using direct HTTP polling.
*/
class PollingSonarrRetriever extends ArrRetriever {
constructor(instanceConfig) {
super(instanceConfig);
}
getRetrieverType() {
return 'sonarr';
}
/**
* Get tags from Sonarr instance
* @returns {Promise<Array>} Array of tag objects
*/
async getTags() {
try {
const response = await axios.get(`${this.url}/api/v3/tag`, {
headers: { 'X-Api-Key': this.apiKey }
});
return response.data;
} catch (error) {
logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`);
return [];
}
}
/**
* Get queue from Sonarr instance
* @returns {Promise<Object>} Queue object with records array
*/
async getQueue() {
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
try {
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeSeries: true, includeEpisode: true, page, pageSize: 1000 }
});
responseData = response.data;
} catch (error) {
console.error(`Sonarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
page <= 50
);
return {
...responseData,
records: allRecords
};
}
/**
* Get history from Sonarr instance
* @param {Object} options - Optional parameters for history fetch
* @param {number} [options.pageSize=100] - Number of records to fetch per page
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
* @param {string} [options.sortKey] - Field to sort by
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
* @param {boolean} [options.includeSeries=true] - Include series data
* @param {boolean} [options.includeEpisode=true] - Include episode data
* @param {string} [options.startDate] - ISO date string for filtering
* @returns {Promise<Object>} History object with records array
*/
async getHistory(options = {}) {
const {
pageSize = 100,
maxPages = 1,
sortKey,
sortDir,
includeSeries = true,
includeEpisode = true,
startDate
} = options;
const instanceName = this.name;
let page = 1;
let allRecords = [];
let responseData = null;
do {
const params = {
page,
pageSize,
includeSeries,
includeEpisode
};
if (sortKey) params.sortKey = sortKey;
if (sortDir) params.sortDir = sortDir;
if (startDate) params.startDate = startDate;
try {
const response = await axios.get(`${this.url}/api/v3/history`, {
headers: { 'X-Api-Key': this.apiKey },
params
});
responseData = response.data;
} catch (error) {
console.error(`Sonarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
throw error;
}
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
allRecords = allRecords.concat(records);
page++;
} while (
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
page <= maxPages
);
return {
...responseData,
records: allRecords
};
}
}
module.exports = PollingSonarrRetriever;
+266
View File
@@ -0,0 +1,266 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class QBittorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this.authCookie = null;
// Sync API incremental state
this.lastRid = 0;
this.torrentMap = new Map();
this.fallbackThisCycle = false;
}
getClientType() {
return 'qbittorrent';
}
async testConnection() {
try {
await this.login();
// Try a simple API call to verify connection
await this.makeRequest('/api/v2/app/version');
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
/**
* Fetches incremental torrent data using the qBittorrent Sync API.
*/
async getMainData() {
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
const data = response.data;
if (data.full_update) {
// Full refresh: rebuild the entire map
this.torrentMap.clear();
if (data.torrents) {
for (const [hash, props] of Object.entries(data.torrents)) {
this.torrentMap.set(hash, { ...props, hash });
}
}
} else {
// Delta update: merge changed fields into existing torrent objects
if (data.torrents) {
for (const [hash, delta] of Object.entries(data.torrents)) {
const existing = this.torrentMap.get(hash) || { hash };
this.torrentMap.set(hash, { ...existing, ...delta });
}
}
}
// Remove torrents that the server reports as deleted
if (data.torrents_removed) {
for (const hash of data.torrents_removed) {
this.torrentMap.delete(hash);
}
}
// Ensure every torrent has a computed 'completed' field for downstream consumers
for (const torrent of this.torrentMap.values()) {
if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) {
torrent.completed = Math.round(torrent.size * torrent.progress);
}
}
this.lastRid = data.rid;
return Array.from(this.torrentMap.values());
}
/**
* Legacy full-list fetch. Used as a fallback when the Sync API fails.
*/
async getTorrentsLegacy() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`);
return response.data;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
if (this.fallbackThisCycle) {
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
const torrents = await this.getTorrentsLegacy();
this.torrentMap = new Map();
for (const torrent of torrents) {
this.torrentMap.set(torrent.hash, torrent);
}
this.lastRid = 0;
return torrents.map(torrent => this.normalizeDownload(torrent));
}
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
this.fallbackThisCycle = true;
try {
const torrents = await this.getTorrentsLegacy();
this.torrentMap = new Map();
for (const torrent of torrents) {
this.torrentMap.set(torrent.hash, torrent);
}
this.lastRid = 0;
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
return [];
}
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('/api/v2/sync/maindata');
const data = response.data;
return {
serverState: data.server_state || {},
rid: data.rid,
fullUpdate: data.full_update
};
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const totalSize = torrent.size;
const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress);
const progress = torrent.progress * 100;
// Map qBittorrent states to our normalized status
const stateMap = {
'downloading': 'Downloading',
'stalledDL': 'Downloading',
'metaDL': 'Downloading',
'forcedDL': 'Downloading',
'allocating': 'Downloading',
'uploading': 'Seeding',
'stalledUP': 'Seeding',
'forcedUP': 'Seeding',
'queuedUP': 'Queued',
'queuedDL': 'Queued',
'checkingUP': 'Checking',
'checkingDL': 'Checking',
'checkingResumeData': 'Checking',
'moving': 'Moving',
'pausedUP': 'Paused',
'pausedDL': 'Paused',
'stoppedUP': 'Stopped',
'stoppedDL': 'Stopped',
'error': 'Error',
'missingFiles': 'Error',
'unknown': 'Unknown'
};
const status = stateMap[torrent.state] || torrent.state;
return {
id: torrent.hash,
title: torrent.name,
type: 'torrent',
client: 'qbittorrent',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: totalSize,
downloaded: downloadedSize,
speed: torrent.dlspeed,
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
category: torrent.category || undefined,
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
savePath: torrent.content_path || torrent.save_path || undefined,
addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined,
raw: torrent
};
}
// Reset fallback flag (called by registry at start of each poll cycle)
resetFallbackFlag() {
this.fallbackThisCycle = false;
}
}
module.exports = QBittorrentClient;
+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;
+258
View File
@@ -0,0 +1,258 @@
// 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, clientStatus] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 }),
this.getClientStatus()
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
const kbpersec = (queueData.queue && queueData.queue.kbpersec) || (clientStatus && clientStatus.kbpersec) || 0;
const globalSpeed = parseFloat(kbpersec) * 1024;
logToFile(`[SABnzbd:${this.name}] Global Speed: ${globalSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
for (const slot of queueData.queue.slots) {
let slotSpeed = 0;
if (slot.status === 'Downloading') {
slotSpeed = globalSpeed;
} else if (slot.status === 'Paused' && slot.kbpersec && parseFloat(slot.kbpersec) > 0) {
slotSpeed = globalSpeed;
}
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history', 0));
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) return null;
return {
status: queueData.status,
speed: queueData.speed,
kbpersec: queueData.kbpersec,
sizeleft: queueData.sizeleft,
mbleft: queueData.mbleft,
mb: queueData.mb,
diskspace1: queueData.diskspace1,
diskspace2: queueData.diskspace2,
loadavg: queueData.loadavg,
pause_int: queueData.pause_int
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(slot, source, speed) {
const isHistory = source === 'history';
const finalSpeed = speed !== undefined ? speed : (slot.kbpersec ? parseFloat(slot.kbpersec) * 1024 : 0);
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
'Paused': 'Paused',
'Waiting': 'Queued',
'Completed': 'Completed',
'Failed': 'Error',
'Verifying': 'Checking',
'Extracting': 'Extracting',
'Moving': 'Moving',
'QuickCheck': 'Checking',
'Repairing': 'Repairing'
};
const status = statusMap[slot.status] || slot.status;
// Calculate progress
let progress = 0;
let downloaded = 0;
let size = 0;
const hasMb = slot.mb !== undefined && slot.mb !== null;
const hasMbLeft = slot.mbleft !== undefined && slot.mbleft !== null;
const mbValue = hasMb ? parseFloat(slot.mb) : 0;
const mbLeftValue = hasMbLeft ? parseFloat(slot.mbleft) : 0;
if (hasMb && hasMbLeft && mbValue !== 0) {
size = mbValue * 1024 * 1024; // Convert MB to bytes
downloaded = (mbValue - mbLeftValue) * 1024 * 1024;
progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
} else if (slot.size) {
// Try to parse size string (e.g., "1.5 GB")
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
if (sizeMatch) {
const [, sizeValue, sizeUnit] = sizeMatch;
const multiplier = this.getUnitMultiplier(sizeUnit);
size = parseFloat(sizeValue) * multiplier;
if (slot.sizeleft) {
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
if (leftMatch) {
const [, leftValue, leftUnit] = leftMatch;
const leftMultiplier = this.getUnitMultiplier(leftUnit);
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
progress = size > 0 ? (downloaded / size) * 100 : 0;
}
}
}
}
// Extract Sonarr/Radarr info from nzb_name if present
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
return {
id: slot.nzo_id || slot.id,
title: slot.filename || slot.nzb_name || 'Unknown',
type: 'usenet',
client: 'sabnzbd',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: finalSpeed,
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: { ...slot, source }
};
}
getUnitMultiplier(unit) {
const unitMap = {
'b': 1,
'byte': 1,
'bytes': 1,
'kb': 1024,
'k': 1024,
'mb': 1024 * 1024,
'm': 1024 * 1024,
'gb': 1024 * 1024 * 1024,
'g': 1024 * 1024 * 1024,
'tb': 1024 * 1024 * 1024 * 1024,
't': 1024 * 1024 * 1024 * 1024
};
return unitMap[unit.toLowerCase()] || 1;
}
calculateEta(timeLeft) {
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
return null;
}
// Parse time in various formats: "0:05:30", "15:30", "330"
const parts = timeLeft.split(':').reverse();
let totalSeconds = 0;
if (parts.length === 1) {
// Just seconds
totalSeconds = parseInt(parts[0], 10);
} else if (parts.length === 2) {
// MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
} else if (parts.length === 3) {
// HH:MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
}
return isNaN(totalSeconds) ? null : totalSeconds;
}
extractArrInfo(filename) {
// Try to extract Sonarr/Radarr info from filename patterns
// This is a simple implementation - could be enhanced with regex patterns
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = SABnzbdClient;
+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;
+5 -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 path = require('path');
const cookieParser = require('cookie-parser');
@@ -82,8 +82,10 @@ const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config');
@@ -252,6 +254,7 @@ function serveIndex(req, res) {
// ---------------------------------------------------------------------------
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// All routes below this point require CSRF validation on mutating methods
app.use('/api', verifyCsrf);
@@ -260,6 +263,7 @@ app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
// SPA catch-all — serve index.html for any unmatched path
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* 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 axios = require('axios');
const crypto = require('crypto');
+165 -930
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
+86 -6
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const axios = require('axios');
@@ -114,6 +115,75 @@ function gatherEpisodes(titleLower, records) {
return episodes;
}
/**
* Deduplicate history items so that for each unique content item (episode or
* movie) only the most-recent record is shown, with the following rules:
*
* - If the most recent event is 'imported' → show it; suppress older failures.
* - If the most recent event is 'failed' and the item currently has a file
* (hasFile = true) → show the failure but flag it as availableForUpgrade:true
* so the UI can indicate the item is available but an upgrade is in progress.
* - If the most recent event is 'failed' and hasFile is false → show normally.
*
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
* Records without a contentId fall through unchanged (no deduplication possible).
*
* @param {Array} items - Already-built history items (unsorted)
* @param {Array} sonarrRaw - Raw Sonarr records (for hasFile lookup)
* @param {Array} radarrRaw - Raw Radarr records (for hasFile lookup)
* @returns {Array}
*/
function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
// Build hasFile lookup: contentId → boolean
const sonarrHasFile = new Map();
for (const r of sonarrRaw) {
const id = r.episodeId;
if (id != null) {
const hf = r.episode && r.episode.hasFile != null ? r.episode.hasFile : undefined;
if (hf !== undefined && !sonarrHasFile.has(id)) sonarrHasFile.set(id, hf);
}
}
const radarrHasFile = new Map();
for (const r of radarrRaw) {
const id = r.movieId;
if (id != null) {
const hf = r.movie && r.movie.hasFile != null ? r.movie.hasFile : undefined;
if (hf !== undefined && !radarrHasFile.has(id)) radarrHasFile.set(id, hf);
}
}
// Group items by dedup key; preserve insertion order (newest first from caller)
const groups = new Map();
const noKey = [];
for (const item of items) {
const cid = item._contentId;
if (cid == null) { noKey.push(item); continue; }
const key = `${item.type}|${item.instanceName}|${cid}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
const result = [...noKey];
for (const [, group] of groups) {
// group[0] is the most recent (items are pushed in date-descending order)
const best = group[0];
if (best.outcome === 'imported') {
result.push(best);
continue;
}
if (best.outcome === 'failed') {
const hasFile = best.type === 'series'
? sonarrHasFile.get(best._contentId)
: radarrHasFile.get(best._contentId);
if (hasFile) best.availableForUpgrade = true;
result.push(best);
continue;
}
result.push(best);
}
return result;
}
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
@@ -223,7 +293,8 @@ router.get('/recent', requireAuth, async (req, res) => {
arrLink: getSonarrLink(series),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.episodeId != null ? record.episodeId : null
};
if (isAdmin) {
@@ -270,7 +341,8 @@ router.get('/recent', requireAuth, async (req, res) => {
arrLink: getRadarrLink(movie),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.movieId != null ? record.movieId : null
};
if (isAdmin) {
@@ -286,16 +358,24 @@ router.get('/recent', requireAuth, async (req, res) => {
}
}
// Sort newest first
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
// Deduplicate: for each content item keep only the most-recent record,
// suppressing failures that were superseded by a successful import.
// Must run before sort so insertion order (newest-first from arr API) is preserved.
const dedupedItems = deduplicateHistoryItems(historyItems, sonarrHistory, radarrHistory);
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
// Strip internal dedup key before sending to client
for (const item of dedupedItems) delete item._contentId;
// Sort newest first
dedupedItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
console.log(`[History] Returning ${dedupedItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
res.json({
user: user.name,
isAdmin,
days,
history: historyItems
history: dedupedItems
});
} catch (err) {
console.error('[History] Error:', err.message);
+181
View File
@@ -1,8 +1,19 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
// Helper to get first Radarr instance (for notification proxy routes)
function getFirstRadarrInstance() {
const instances = getRadarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
@@ -55,4 +66,174 @@ router.get('/movies', async (req, res) => {
}
});
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
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) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Radarr] Test response status:', error.response.status);
console.error('[Radarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
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) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
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(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Radarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Radarr] Response status:', error.response.status);
console.error('[Radarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router;
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
+181
View File
@@ -1,8 +1,19 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
// Helper to get first Sonarr instance (for notification proxy routes)
function getFirstSonarrInstance() {
const instances = getSonarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
@@ -55,4 +66,174 @@ router.get('/series', async (req, res) => {
}
});
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
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) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Sonarr] Test response status:', error.response.status);
console.error('[Sonarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
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) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
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(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Sonarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Sonarr] Response status:', error.response.status);
console.error('[Sonarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router;
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
// Admin-only status page with cache stats
router.get('/', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const cacheStats = cache.getStats();
const uptime = process.uptime();
// Get webhook metrics
const webhookMetrics = getGlobalWebhookMetrics();
// Check webhook configuration for each service
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
// Find Sonarr and Radarr metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
}
}
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
nodeVersion: process.version,
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
},
polling: {
enabled: POLLING_ENABLED,
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
lastPoll: getLastPollTimings()
},
cache: cacheStats,
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router;
+325
View File
@@ -0,0 +1,325 @@
// 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);
}
}
// Prune the replay cache once per minute
setInterval(pruneReplayCache, 60 * 1000).unref();
function isReplay(eventType, instanceName, eventDate) {
if (!eventDate) return false;
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
if (recentEvents.has(key)) return true;
recentEvents.set(key, Date.now());
return false;
}
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// Event classification — determines which cache keys to refresh
const QUEUE_EVENTS = new Set([
'Grab',
'Download',
'DownloadFailed',
'ManualInteractionRequired'
]);
const HISTORY_EVENTS = new Set([
'DownloadFolderImported',
'ImportFailed',
'EpisodeFileRenamed',
'MovieFileRenamed',
'EpisodeFileRenamedBySeries'
]);
/**
* 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;
const sonarrInstances = getSonarrInstances();
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('sonarr', eventType).catch(err => {
logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Sonarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
/**
* 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;
const radarrInstances = getRadarrInstances();
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('radarr', eventType).catch(err => {
logToFile(`[Webhook] Radarr background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Radarr error: ${error.message}`);
res.status(200).json({ received: true });
}
});
module.exports = router;
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Helper function to extract poster/cover art URL from a movie or series object
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
// Fallback to fanart if no poster
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
// Extract import issues from a Sonarr/Radarr queue record
function getImportIssues(queueRecord) {
if (!queueRecord) return null;
const state = queueRecord.trackedDownloadState;
const status = queueRecord.trackedDownloadStatus;
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
const messages = [];
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
for (const sm of queueRecord.statusMessages) {
if (sm.messages && sm.messages.length > 0) {
messages.push(...sm.messages);
} else if (sm.title) {
messages.push(sm.title);
}
}
}
if (queueRecord.errorMessage) {
messages.push(queueRecord.errorMessage);
}
if (messages.length === 0) return null;
return messages;
}
// Helper to build Sonarr web UI link for a series
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
// Helper to build Radarr web UI link for a movie
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
// Determine if a download can be blocklisted by the current user
// Admins: always true (they have arrQueueId)
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
function canBlocklist(download, isAdmin) {
if (isAdmin) return true;
if (download.importIssues && download.importIssues.length > 0) return true;
if (download.qbittorrent && download.addedOn && download.availability) {
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
const addedOn = new Date(download.addedOn).getTime();
const isOldEnough = addedOn < oneHourAgo;
const availability = parseFloat(download.availability);
const isLowAvailability = availability < 100;
return isOldEnough && isLowAvailability;
}
return false;
}
// Extract episode info from a Sonarr queue/history record.
// Returns { season, episode, title } or null if data is missing.
function extractEpisode(record) {
if (!record) return null;
const ep = record.episode || {};
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
if (s == null || e == null) return null;
const title = ep.title || null;
return { season: s, episode: e, title };
}
// Find all episodes associated with a download by matching all queue/history records
// that share the same title string. Returns sorted array of { season, episode, title }.
function gatherEpisodes(titleLower, sonarrRecords) {
const episodes = [];
const seen = new Set();
for (const r of sonarrRecords) {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
const ep = extractEpisode(r);
if (ep) {
const key = `${ep.season}x${ep.episode}`;
if (!seen.has(key)) {
seen.add(key);
episodes.push(ep);
}
}
}
}
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
return episodes;
}
module.exports = {
getCoverArt,
getImportIssues,
getSonarrLink,
getRadarrLink,
canBlocklist,
extractEpisode,
gatherEpisodes
};
+112
View File
@@ -0,0 +1,112 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* DownloadBuilder - Aggregates and matches download data from multiple sources.
* This service processes cached data from SABnzbd, qBittorrent, Sonarr, and Radarr to build
* a unified view of downloads for each user, matching downloads to media metadata via tags.
*/
const DownloadMatcher = require('./DownloadMatcher');
/**
* Builds a unified list of downloads for a user from multiple download clients.
* Matches SABnzbd and qBittorrent downloads to Sonarr/Radarr activity using tags.
* @param {Object} cacheSnapshot - Cached data from all services
* @param {Object} options - User context and metadata maps
* @param {string} options.username - Lowercase username for tag matching
* @param {string} options.usernameSanitized - Original username
* @param {boolean} options.isAdmin - Whether user is admin
* @param {boolean} options.showAll - Whether to show all users' downloads
* @param {Map} options.seriesMap - Map of seriesId to series object
* @param {Map} options.moviesMap - Map of movieId to movie object
* @param {Map} options.sonarrTagMap - Map of Sonarr tag IDs to labels
* @param {Map} options.radarrTagMap - Map of Radarr tag IDs to labels
* @param {Map} options.embyUserMap - Map of Emby users for admin view
* @returns {Array} Array of download objects for the user
*/
function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }) {
// Input validation
if (!cacheSnapshot || typeof cacheSnapshot !== 'object') {
console.error('[DownloadBuilder] Invalid cacheSnapshot provided');
return [];
}
try {
// Handle null/undefined cache data
const sabnzbdQueue = cacheSnapshot.sabnzbdQueue || { data: { queue: { slots: [] } } };
const sabnzbdHistory = cacheSnapshot.sabnzbdHistory || { data: { history: { slots: [] } } };
const sonarrQueue = cacheSnapshot.sonarrQueue || { data: { records: [] } };
const sonarrHistory = cacheSnapshot.sonarrHistory || { data: { records: [] } };
const radarrQueue = cacheSnapshot.radarrQueue || { data: { records: [] } };
const radarrHistory = cacheSnapshot.radarrHistory || { data: { records: [] } };
const qbittorrentTorrents = cacheSnapshot.qbittorrentTorrents || [];
// Get queue status for SABnzbd
const queueStatus = sabnzbdQueue.data?.queue?.status || null;
const queueSpeed = sabnzbdQueue.data?.queue?.speed || null;
const queueKbpersec = sabnzbdQueue.data?.queue?.kbpersec || null;
// Build context for matching functions
const context = {
sonarrQueueRecords: sonarrQueue.data?.records || [],
sonarrHistoryRecords: sonarrHistory.data?.records || [],
radarrQueueRecords: radarrQueue.data?.records || [],
radarrHistoryRecords: radarrHistory.data?.records || [],
seriesMap: seriesMap || new Map(),
moviesMap: moviesMap || new Map(),
sonarrTagMap: sonarrTagMap || new Map(),
radarrTagMap: radarrTagMap || new Map(),
username,
isAdmin,
showAll,
embyUserMap: embyUserMap || new Map(),
queueStatus,
queueSpeed,
queueKbpersec
};
// Match all download sources
const userDownloads = [];
const seenDownloadKeys = new Set();
if (sabnzbdQueue.data?.queue?.slots) {
const sabMatches = DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
for (const dl of sabMatches) {
const key = `${dl.type}:${dl.title}`;
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
userDownloads.push(dl);
}
}
}
if (sabnzbdHistory.data?.history?.slots) {
const sabHistoryMatches = DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
for (const dl of sabHistoryMatches) {
const key = `${dl.type}:${dl.title}`;
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
userDownloads.push(dl);
}
}
}
const torrentMatches = DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
for (const dl of torrentMatches) {
const key = `${dl.type}:${dl.title}`;
if (!seenDownloadKeys.has(key)) {
seenDownloadKeys.add(key);
userDownloads.push(dl);
}
}
return userDownloads;
} catch (error) {
console.error('[DownloadBuilder] Error building user downloads:', error.message);
return [];
}
}
module.exports = {
buildUserDownloads
};
+561
View File
@@ -0,0 +1,561 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* DownloadMatcher - Matches download client data to Sonarr/Radarr activity.
* Contains logic for matching SABnzbd slots and qBittorrent torrents to media metadata
* via download IDs and title matching.
*/
const { mapTorrentToDownload } = require('../utils/qbittorrent');
const TagMatcher = require('./TagMatcher');
const DownloadAssembler = require('./DownloadAssembler');
/**
* Builds a Map of series metadata from Sonarr queue and history records.
* @param {Array} queueRecords - Sonarr queue records
* @param {Array} historyRecords - Sonarr history records
* @returns {Map} Map of seriesId to series object
*/
function buildSeriesMapFromRecords(queueRecords, historyRecords) {
const seriesMap = new Map();
for (const r of queueRecords) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
}
for (const r of historyRecords) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
}
return seriesMap;
}
/**
* Builds a Map of movie metadata from Radarr queue and history records.
* @param {Array} queueRecords - Radarr queue records
* @param {Array} historyRecords - Radarr history records
* @returns {Map} Map of movieId to movie object
*/
function buildMoviesMapFromRecords(queueRecords, historyRecords) {
const moviesMap = new Map();
for (const r of queueRecords) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
}
for (const r of historyRecords) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
}
return moviesMap;
}
/**
* Determines the status and speed for a SABnzbd slot based on queue state.
* @param {Object} slot - SABnzbd queue slot
* @param {string} queueStatus - Overall queue status (e.g., 'Paused')
* @param {string} queueSpeed - Queue speed string
* @param {string} queueKbpersec - Queue speed in KB/s
* @returns {Object} Object with status and speed properties
*/
function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) {
if (queueStatus === 'Paused') {
return { status: 'Paused', speed: '0' };
}
return {
status: slot.status || 'Unknown',
speed: queueSpeed || queueKbpersec || '0'
};
}
/**
* Matches SABnzbd queue slots to Sonarr/Radarr activity using download IDs and title matching.
* @param {Array} slots - SABnzbd queue slots
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of matched download objects
*/
function matchSabSlots(slots, context) {
const {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
queueStatus,
queueSpeed,
queueKbpersec
} = context;
const matched = [];
for (const slot of slots) {
const nzbName = slot.filename || slot.nzbname;
if (!nzbName) continue;
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
const nzbNameLower = nzbName.toLowerCase();
// Normalize SAB name (dots to spaces) for better matching
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
// Try to match by downloadId first (most reliable)
const sabDownloadId = slot.nzo_id || slot.id;
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
// Also check HISTORY by downloadId
if (!sonarrMatch && sabDownloadId) {
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
}
if (!radarrMatch && sabDownloadId) {
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
}
// Fallback: Check by title matching
if (!sonarrMatch) {
sonarrMatch = sonarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
// Also check HISTORY (completed downloads) if no queue match
if (!sonarrMatch) {
sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
// Calculate progress from SABnzbd slot data
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
? parseFloat(slot.mbleft || slot.mbmissing)
: 0;
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
const dlObj = {
type: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
mbmissing: slot.mbleft,
size: Math.round(slot.mb * 1024 * 1024),
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.arrQueueId = sonarrMatch.id;
dlObj.arrType = 'sonarr';
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
dlObj.arrContentId = sonarrMatch.episodeId || null;
dlObj.arrContentType = 'episode';
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
matched.push(dlObj);
}
}
}
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
// Calculate progress from SABnzbd slot data
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
? parseFloat(slot.mbleft || slot.mbmissing)
: 0;
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
const dlObj = {
type: 'movie',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(movie),
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
mbmissing: slot.mbleft,
size: Math.round(slot.mb * 1024 * 1024),
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft,
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.arrQueueId = radarrMatch.id;
dlObj.arrType = 'radarr';
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
dlObj.arrContentId = radarrMatch.movieId || null;
dlObj.arrContentType = 'movie';
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
matched.push(dlObj);
}
}
}
}
return matched;
}
/**
* Matches SABnzbd history slots to Sonarr/Radarr activity using title matching.
* @param {Array} slots - SABnzbd history slots
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of matched download objects
*/
function matchSabHistory(slots, context) {
const {
sonarrHistoryRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap
} = context;
const matched = [];
for (const slot of slots) {
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
if (!nzbName) continue;
const nzbNameLower = nzbName.toLowerCase();
const sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = {
type: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slot.status,
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
}
matched.push(dlObj);
}
}
}
const radarrMatch = radarrHistoryRecords.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = {
type: 'movie',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(movie),
status: slot.status,
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
}
matched.push(dlObj);
}
}
}
}
return matched;
}
/**
* Matches qBittorrent torrents to Sonarr/Radarr activity using title matching.
* @param {Array} torrents - qBittorrent torrent list
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of matched download objects
*/
function matchTorrents(torrents, context) {
const {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap
} = context;
const matched = [];
for (const torrent of torrents) {
const torrentName = torrent.name || '';
if (!torrentName) continue;
const torrentNameLower = torrentName.toLowerCase();
let matchedAny = false;
const sonarrMatch = sonarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent);
download.id = download.hash || torrent.hash;
download.progress = parseFloat(download.progress) || torrent.progress || 0;
download.speed = download.rawSpeed || torrent.dlspeed || 0;
Object.assign(download, {
type: 'series',
coverArt: DownloadAssembler.getCoverArt(series),
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) download.importIssues = issues;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = DownloadAssembler.getSonarrLink(series);
download.arrQueueId = sonarrMatch.id;
download.arrType = 'sonarr';
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
download.arrInstanceKey = sonarrMatch._instanceKey || null;
download.arrContentId = sonarrMatch.episodeId || null;
download.arrContentType = 'episode';
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
matched.push(download);
matchedAny = true;
continue;
}
}
}
const radarrMatch = radarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent);
download.id = download.hash || torrent.hash;
download.progress = parseFloat(download.progress) || torrent.progress || 0;
download.speed = download.rawSpeed || torrent.dlspeed || 0;
Object.assign(download, {
type: 'movie',
coverArt: DownloadAssembler.getCoverArt(movie),
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) download.importIssues = issues;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.arrQueueId = radarrMatch.id;
download.arrType = 'radarr';
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
download.arrInstanceKey = radarrMatch._instanceKey || null;
download.arrContentId = radarrMatch.movieId || null;
download.arrContentType = 'movie';
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
matched.push(download);
matchedAny = true;
continue;
}
}
}
const sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
if (series) {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent);
Object.assign(download, {
type: 'series',
coverArt: DownloadAssembler.getCoverArt(series),
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = DownloadAssembler.getSonarrLink(series);
}
matched.push(download);
matchedAny = true;
continue;
}
}
}
const radarrHistoryMatch = radarrHistoryRecords.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
if (movie) {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent);
download.id = download.hash || torrent.hash;
download.progress = parseFloat(download.progress) || torrent.progress || 0;
download.speed = download.rawSpeed || torrent.dlspeed || 0;
Object.assign(download, {
type: 'movie',
coverArt: DownloadAssembler.getCoverArt(movie),
movieName: movie.title,
movieInfo: radarrHistoryMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = DownloadAssembler.getRadarrLink(movie);
}
matched.push(download);
matchedAny = true;
}
}
}
}
return matched;
}
module.exports = {
buildSeriesMapFromRecords,
buildMoviesMapFromRecords,
getSlotStatusAndSpeed,
matchSabSlots,
matchSabHistory,
matchTorrents
};
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const cache = require('../utils/cache');
// Return all resolved tag labels for a series/movie.
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
// For Sonarr: tags are objects with a label property.
function extractAllTags(tags, tagMap) {
if (!tags || tags.length === 0) return [];
if (tagMap) {
return tags.map(id => tagMap.get(id)).filter(Boolean);
}
return tags.map(t => t && t.label).filter(Boolean);
}
// Return the tag label that matches the current username, or null.
function extractUserTag(tags, tagMap, username) {
const allLabels = extractAllTags(tags, tagMap);
if (!allLabels.length) return null;
if (username) {
const match = allLabels.find(label => tagMatchesUser(label, username));
if (match) return match;
}
return null;
}
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
function sanitizeTagLabel(input) {
if (!input) return '';
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
// Check if a tag matches the username: exact match first, then sanitized match
function tagMatchesUser(tag, username) {
if (!tag || !username) return false;
const tagLower = tag.toLowerCase();
// Exact match (handles users whose tags weren't mangled)
if (tagLower === username) return true;
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
if (tagLower === sanitizeTagLabel(username)) return true;
return false;
}
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
async function getEmbyUsers() {
const cached = cache.get('emby:users');
if (cached) return cached;
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
// Build map: both raw lowercase and sanitized form -> display name
const map = new Map();
for (const u of response.data) {
const name = u.Name || '';
map.set(name.toLowerCase(), name);
map.set(sanitizeTagLabel(name), name);
}
cache.set('emby:users', map, 60000);
return map;
} catch (err) {
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
return new Map();
}
}
// Classify each tag label: matched to a known Emby user, or unmatched.
// Returns array of { label, matchedUser: string|null }
function buildTagBadges(allTags, embyUserMap) {
return allTags.map(label => {
const lower = label.toLowerCase();
const sanitized = sanitizeTagLabel(label);
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
return { label, matchedUser: displayName };
});
}
module.exports = {
extractAllTags,
extractUserTag,
sanitizeTagLabel,
tagMatchesUser,
getEmbyUsers,
buildTagBadges
};
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
/**
* Check if Sofarr webhook is configured in a Sonarr/Radarr instance.
* @param {Object} instance - The Sonarr/Radarr instance config
* @param {string} type - 'Sonarr' or 'Radarr'
* @returns {Promise<boolean>} true if webhook is configured
*/
async function checkWebhookConfigured(instance, type) {
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey },
timeout: 5000
});
const notifications = response.data || [];
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
} catch (err) {
console.log(`[WebhookStatus] Failed to check ${type} webhook config: ${err.message}`);
return false;
}
}
/**
* Aggregate webhook metrics for a service type.
* @param {Object} metricsMap - Map of instance URLs to their metrics
* @param {boolean} configured - Whether the service is configured
* @returns {Object|null} Aggregated metrics or null if not configured
*/
function aggregateMetrics(metricsMap, configured) {
const values = Object.values(metricsMap);
if (values.length === 0) {
// Return default metrics if configured but no events yet
return configured ? {
enabled: true,
eventsReceived: 0,
pollsSkipped: 0,
lastEvent: null
} : null;
}
return {
enabled: true,
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
lastEvent: values.reduce((latest, m) => {
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
}, 0)
};
}
module.exports = {
checkWebhookConfigured,
aggregateMetrics
};
+405
View File
@@ -0,0 +1,405 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const cache = require('./cache');
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);
const uniqueKey = `${config.type}:${config.id}`;
this.retrievers.set(uniqueKey, retriever);
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
},
/**
* Get all registered retrievers
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getAllRetrievers() {
return Array.from(this.retrievers.values());
},
/**
* Get retriever by instance ID
* @param {string} instanceId - The instance ID
* @returns {ArrRetriever|null} Retriever instance or null if not found
*/
getRetriever(instanceId) {
return this.retrievers.get(instanceId) || null;
},
/**
* Get retrievers by type
* @param {string} type - Retriever type ('sonarr', 'radarr')
* @returns {Array<ArrRetriever>} Array of retriever instances
*/
getRetrieversByType(type) {
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
},
/**
* Get tags from all retrievers
* @returns {Promise<Array<Object>>} Array of tag results with instance info
*/
async getAllTags() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch tags from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get queue from all retrievers
* @returns {Promise<Array<Object>>} Array of queue results with instance info
*/
async getAllQueues() {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch queues from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get history from all retrievers
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Array<Object>>} Array of history results with instance info
*/
async getAllHistory(options = {}) {
const retrievers = this.getAllRetrievers();
if (retrievers.length === 0) {
return [];
}
// Fetch history from all retrievers in parallel
const results = await Promise.allSettled(
retrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
},
/**
* Get tags grouped by retriever type
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
*/
async getTagsByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrTags = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
const radarrTags = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const tags = await retriever.getTags();
return { instance: retriever.getInstanceId(), data: tags };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: [] };
}
})
);
return {
sonarr: sonarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrTags
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get queue grouped by retriever type
* @returns {Promise<Object>} Queue grouped by retriever type
*/
async getQueuesByType() {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrQueues = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrQueues = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const queue = await retriever.getQueue();
return { instance: retriever.getInstanceId(), data: queue };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrQueues
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
},
/**
* Get history grouped by retriever type
* @param {Object} options - Optional parameters for history fetch
* @returns {Promise<Object>} History grouped by retriever type
*/
async getHistoryByType(options = {}) {
const sonarrRetrievers = this.getRetrieversByType('sonarr');
const radarrRetrievers = this.getRetrieversByType('radarr');
const sonarrHistory = await Promise.allSettled(
sonarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
const radarrHistory = await Promise.allSettled(
radarrRetrievers.map(async (retriever) => {
try {
const history = await retriever.getHistory(options);
return { instance: retriever.getInstanceId(), data: history };
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
return { instance: retriever.getInstanceId(), data: { records: [] } };
}
})
);
return {
sonarr: sonarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value),
radarr: radarrHistory
.filter(result => result.status === 'fulfilled')
.map(result => result.value)
};
}
};
/**
* Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
*/
function sanitizeTagLabel(input) {
if (!input) return '';
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
/**
* Check if a tag matches the username: exact match first, then sanitized match
*/
function tagMatchesUser(tag, username) {
if (!tag || !username) return false;
const tagLower = tag.toLowerCase();
const usernameLower = username.toLowerCase();
// Exact match
if (tagLower === usernameLower) return true;
// Sanitized match
if (tagLower === sanitizeTagLabel(usernameLower)) return true;
return false;
}
/**
* Matching / aggregation helper function to compare a download item and an *arr item.
*/
function matchDownload(download, arrItem, username, tagMap) {
if (!download || !arrItem) return false;
// 1. First attempt an exact ID match using the stable fields that exist in the fetched data
if (download.arrInfo) {
// Sonarr stable IDs
if (download.arrInfo.episodeFileId !== undefined && arrItem.episodeFileId !== undefined) {
if (download.arrInfo.episodeFileId === arrItem.episodeFileId) return true;
}
if (download.arrInfo.episodeId !== undefined && arrItem.episodeId !== undefined) {
if (download.arrInfo.episodeId === arrItem.episodeId) return true;
}
if (download.arrInfo.seriesId !== undefined && arrItem.seriesId !== undefined) {
if (download.arrInfo.seriesId === arrItem.seriesId) return true;
}
// Radarr stable IDs
if (download.arrInfo.movieFileId !== undefined && arrItem.movieFileId !== undefined) {
if (download.arrInfo.movieFileId === arrItem.movieFileId) return true;
}
if (download.arrInfo.movieId !== undefined && arrItem.movieId !== undefined) {
if (download.arrInfo.movieId === arrItem.movieId) return true;
}
}
// 2. Only fall back to the existing title + tag string matching if no ID match is possible
const dlTitle = (download.title || '').toLowerCase();
const arrTitle = (arrItem.title || arrItem.sourceTitle || '').toLowerCase();
const titleMatches = dlTitle && arrTitle && (dlTitle.includes(arrTitle) || arrTitle.includes(dlTitle));
if (!titleMatches) return false;
// Preserve the existing lowercase-username tag logic exactly
if (!username) return true;
const getLabels = (item) => {
if (!item) return [];
const tags = item.tags || (item.series && item.series.tags) || (item.movie && item.movie.tags) || [];
return tags.map(t => {
if (typeof t === 'object' && t !== null) {
return t.label || t.name;
}
if (tagMap && tagMap.has && tagMap.has(t)) {
return tagMap.get(t);
}
// Try resolving from cache as fallback
const cachedSonarrTags = cache.get('poll:sonarr-tags') || [];
const cachedRadarrTags = cache.get('poll:radarr-tags') || [];
const allCachedTags = [...cachedSonarrTags, ...cachedRadarrTags];
const found = allCachedTags.find(tag => tag && tag.id === t);
if (found) return found.label || found.name;
return t;
}).filter(Boolean);
};
const dlTags = getLabels(download);
const arrTags = getLabels(arrItem);
const allTags = [...dlTags, ...arrTags];
return allTags.some(tag => tagMatchesUser(tag, username));
}
// Attach matching helper functions to the registry object
arrRetrieverRegistry.matchDownload = matchDownload;
arrRetrieverRegistry.matchDownloadToArr = matchDownload;
arrRetrieverRegistry.aggregateMatch = matchDownload;
arrRetrieverRegistry.matchingHelper = matchDownload;
arrRetrieverRegistry.compareDownloadAndArr = matchDownload;
module.exports = arrRetrieverRegistry;
+61
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
class MemoryCache {
@@ -71,4 +72,64 @@ class MemoryCache {
const cache = new MemoryCache();
// Webhook metrics for polling optimization
// These are stored separately from regular cache entries
const webhookMetrics = {
// Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped }
instances: new Map(),
// Global metrics
lastGlobalWebhookTimestamp: null,
totalWebhookEventsReceived: 0
};
function getWebhookMetrics(instanceUrl) {
if (!instanceUrl) return null;
return webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
}
function updateWebhookMetrics(instanceUrl) {
const now = Date.now();
webhookMetrics.lastGlobalWebhookTimestamp = now;
webhookMetrics.totalWebhookEventsReceived++;
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.lastWebhookTimestamp = now;
metrics.eventsReceived++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function incrementPollsSkipped(instanceUrl) {
if (instanceUrl) {
const metrics = webhookMetrics.instances.get(instanceUrl) || {
lastWebhookTimestamp: null,
eventsReceived: 0,
pollsSkipped: 0
};
metrics.pollsSkipped++;
webhookMetrics.instances.set(instanceUrl, metrics);
}
}
function getGlobalWebhookMetrics() {
return {
lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp,
totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived,
instances: Object.fromEntries(webhookMetrics.instances)
};
}
module.exports = cache;
module.exports.getWebhookMetrics = getWebhookMetrics;
module.exports.updateWebhookMetrics = updateWebhookMetrics;
module.exports.incrementPollsSkipped = incrementPollsSkipped;
module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics;
+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');
// 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 = {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances,
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances,
getWebhookSecret,
getSofarrBaseUrl,
parseInstances,
validateInstanceUrl
};
+255
View File
@@ -0,0 +1,255 @@
// 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);
const uniqueKey = `${config.type}:${config.id}`;
this.clients.set(uniqueKey, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
} 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()
};
+241 -26
View File
@@ -1,11 +1,26 @@
const axios = require('axios');
// Copyright (c) 2026 Gordon Bolton. MIT License.
const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config');
const arrRetrieverRegistry = require('./arrRetrievers');
// Cache TTL for recent-history data: 5 minutes.
// History changes slowly compared to active downloads.
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
// Staged loading configuration
const INITIAL_PAGE_SIZE = 100;
const MAX_TOTAL_RECORDS = 1000;
const MAX_PAGES = 10; // 10 pages * 100 records = 1000 total
// Background fetch state to prevent concurrent fetches
const backgroundFetchState = {
sonarr: { inProgress: false, lastFetchTime: 0 },
radarr: { inProgress: false, lastFetchTime: 0 }
};
// Event subscribers for history updates
const historyUpdateSubscribers = new Set();
// Sonarr event types that represent a successful import
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
// Sonarr event types that represent a failed import
@@ -17,29 +32,43 @@ const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
/**
* Fetch recent history records from all Sonarr instances for the given date window.
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
*/
async function fetchSonarrHistory(since) {
const cacheKey = 'history:sonarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
if (cached) {
// Only trigger background refresh if cache is incomplete (less than max records)
if (!backgroundFetchState.sonarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
triggerBackgroundSonarrFetch(since);
}
return cached;
}
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances();
const results = await Promise.all(instances.map(async inst => {
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
// Stage 1: Fetch initial batch (100 records)
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: {
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
}
const response = await retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: 1,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
});
const records = (response.data && response.data.records) || [];
const records = (response && response.records) || [];
return records.map(r => {
if (r.series) r.series._instanceUrl = inst.url;
if (r.series) r.series._instanceName = inst.name || inst.id;
@@ -55,34 +84,118 @@ async function fetchSonarrHistory(since) {
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
// Stage 2: Trigger background fetch for remaining records
triggerBackgroundSonarrFetch(since);
return flat;
}
/**
* Trigger background fetch for remaining Sonarr history records.
* Uses the retriever's built-in pagination to fetch up to 1000 records.
*/
async function triggerBackgroundSonarrFetch(since) {
if (backgroundFetchState.sonarr.inProgress) return;
// Debounce: don't fetch if we fetched within the last minute
const now = Date.now();
if (now - backgroundFetchState.sonarr.lastFetchTime < 60000) return;
backgroundFetchState.sonarr.inProgress = true;
backgroundFetchState.sonarr.lastFetchTime = now;
try {
await arrRetrieverRegistry.initialize();
const instances = getSonarrInstances();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
// Fetch all records up to MAX_PAGES using built-in pagination
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: MAX_PAGES,
sortKey: 'date',
sortDir: 'descending',
includeSeries: true,
includeEpisode: true,
startDate: since.toISOString()
});
const records = (response && response.records) || [];
return records.map(r => {
if (r.series) r.series._instanceUrl = inst.url;
if (r.series) r.series._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Sonarr background fetch ${inst.id} error:`, err.message);
return [];
}
}));
const allRecords = results.flat();
// Only update cache if we got records (don't overwrite with empty data on failure)
if (allRecords.length > 0) {
cache.set('history:sonarr', allRecords, HISTORY_CACHE_TTL);
// Emit SSE event for history update
emitHistoryUpdate('sonarr');
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Sonarr records`);
}
} catch (err) {
console.error('[HistoryFetcher] Background Sonarr fetch error:', err.message);
} finally {
backgroundFetchState.sonarr.inProgress = false;
}
}
/**
* Fetch recent history records from all Radarr instances for the given date window.
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
* @param {Date} since - Only include records on or after this date
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
*/
async function fetchRadarrHistory(since) {
const cacheKey = 'history:radarr';
const cached = cache.get(cacheKey);
if (cached) return cached;
if (cached) {
// Only trigger background refresh if cache is incomplete (less than max records)
if (!backgroundFetchState.radarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
triggerBackgroundRadarrFetch(since);
}
return cached;
}
// Ensure retrievers are initialized
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances();
const results = await Promise.all(instances.map(async inst => {
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
// Stage 1: Fetch initial batch (100 records)
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: {
pageSize: 100,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
}
const response = await retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: 1,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
});
const records = (response.data && response.data.records) || [];
const records = (response && response.records) || [];
return records.map(r => {
if (r.movie) r.movie._instanceUrl = inst.url;
if (r.movie) r.movie._instanceName = inst.name || inst.id;
@@ -98,9 +211,109 @@ async function fetchRadarrHistory(since) {
const flat = results.flat();
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
// Stage 2: Trigger background fetch for remaining records
triggerBackgroundRadarrFetch(since);
return flat;
}
/**
* Trigger background fetch for remaining Radarr history records.
* Uses the retriever's built-in pagination to fetch up to 1000 records.
*/
async function triggerBackgroundRadarrFetch(since) {
if (backgroundFetchState.radarr.inProgress) return;
// Debounce: don't fetch if we fetched within the last minute
const now = Date.now();
if (now - backgroundFetchState.radarr.lastFetchTime < 60000) return;
backgroundFetchState.radarr.inProgress = true;
backgroundFetchState.radarr.lastFetchTime = now;
try {
await arrRetrieverRegistry.initialize();
const instances = getRadarrInstances();
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
// Fetch all records up to MAX_PAGES using built-in pagination
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
const inst = instances.find(i => i.id === retriever.getInstanceId());
if (!inst) return [];
try {
const response = await retriever.getHistory({
pageSize: INITIAL_PAGE_SIZE,
maxPages: MAX_PAGES,
sortKey: 'date',
sortDir: 'descending',
includeMovie: true,
startDate: since.toISOString()
});
const records = (response && response.records) || [];
return records.map(r => {
if (r.movie) r.movie._instanceUrl = inst.url;
if (r.movie) r.movie._instanceName = inst.name || inst.id;
r._instanceUrl = inst.url;
r._instanceName = inst.name || inst.id;
return r;
});
} catch (err) {
console.error(`[HistoryFetcher] Radarr background fetch ${inst.id} error:`, err.message);
return [];
}
}));
const allRecords = results.flat();
// Only update cache if we got records (don't overwrite with empty data on failure)
if (allRecords.length > 0) {
cache.set('history:radarr', allRecords, HISTORY_CACHE_TTL);
// Emit SSE event for history update
emitHistoryUpdate('radarr');
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Radarr records`);
}
} catch (err) {
console.error('[HistoryFetcher] Background Radarr fetch error:', err.message);
} finally {
backgroundFetchState.radarr.inProgress = false;
}
}
/**
* Subscribe to history update events.
* @param {Function} callback - Function to call when history is updated
*/
function onHistoryUpdate(callback) {
historyUpdateSubscribers.add(callback);
}
/**
* Unsubscribe from history update events.
* @param {Function} callback - Function to remove from subscribers
*/
function offHistoryUpdate(callback) {
historyUpdateSubscribers.delete(callback);
}
/**
* Emit SSE event for history update.
* Notifies all subscribers when history cache is updated.
*/
function emitHistoryUpdate(type) {
console.log(`[HistoryFetcher] History updated for ${type}, notifying ${historyUpdateSubscribers.size} subscribers`);
historyUpdateSubscribers.forEach(callback => {
try {
callback(type);
} catch (err) {
console.error('[HistoryFetcher] Error in history update subscriber:', err.message);
}
});
}
/**
* Classify a Sonarr history record's event type.
* @param {string} eventType
@@ -138,5 +351,7 @@ module.exports = {
classifySonarrEvent,
classifyRadarrEvent,
invalidateHistoryCache,
onHistoryUpdate,
offHistoryUpdate,
HISTORY_CACHE_TTL
};
+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,
// 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 path = require('path');
+215 -120
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 cache = require('./cache');
const { getTorrents } = require('./qbittorrent');
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
const arrRetrieverRegistry = require('./arrRetrievers');
const {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('./config');
@@ -14,6 +14,13 @@ const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false'
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
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 lastPollTimings = null;
@@ -30,6 +37,42 @@ async function timed(label, fn) {
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() {
if (polling) {
console.log('[Poller] Previous poll still running, skipping');
@@ -39,93 +82,65 @@ async function pollAllServices() {
const start = Date.now();
try {
const sabInstances = getSABnzbdInstances();
// Ensure download clients and *arr retrievers are initialized
await initializeClients();
await arrRetrieverRegistry.initialize();
const sonarrInstances = getSonarrInstances();
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
const results = await Promise.all([
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } };
})
))),
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { history: { slots: [] } } };
})
))),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.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] Sonarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeSeries: true, includeEpisode: true }
}).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 [];
}))
timed('Download Clients', async () => {
const downloadsByType = await getDownloadsByClientType();
return downloadsByType;
}),
shouldPollSonarr ? timed('Sonarr Tags', async () => {
const tagsByType = await arrRetrieverRegistry.getTagsByType();
return tagsByType.sonarr || [];
}) : timed('Sonarr Tags', async () => []),
shouldPollSonarr ? timed('Sonarr Queue', async () => {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
return queuesByType.sonarr || [];
}) : timed('Sonarr Queue', async () => []),
shouldPollSonarr ? timed('Sonarr History', async () => {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
return historyByType.sonarr || [];
}) : timed('Sonarr History', async () => []),
shouldPollRadarr ? timed('Radarr Queue', async () => {
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
return queuesByType.radarr || [];
}) : timed('Radarr Queue', async () => []),
shouldPollRadarr ? timed('Radarr History', async () => {
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
return historyByType.radarr || [];
}) : timed('Radarr History', async () => []),
shouldPollRadarr ? timed('Radarr Tags', async () => {
const tagsByType = await arrRetrieverRegistry.getTagsByType();
return tagsByType.radarr || [];
}) : timed('Radarr Tags', async () => []),
]);
const [
{ result: sabQueues }, { result: sabHistories },
{ result: downloadsByType },
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults },
{ result: qbittorrentTorrents }
{ result: radarrTagsResults }
] = results;
// Store per-task timings
@@ -140,54 +155,134 @@ async function pollAllServices() {
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// SABnzbd
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
// Download Clients (SABnzbd, qBittorrent, Transmission)
// 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,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
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,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};
// 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', {
...sabQueueLegacy,
...sabStatus
}, 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
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
if (shouldPollSonarr) {
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
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
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
if (shouldPollRadarr) {
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
const key = inst ? inst.apiKey : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
r._instanceUrl = url;
r._instanceKey = key;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, 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
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
// qBittorrent (already set above in download clients section)
const elapsed = Date.now() - start;
console.log(`[Poller] Poll complete in ${elapsed}ms`);
+43 -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 { getQbittorrentInstances } = require('./config');
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;
}
const { initializeClients, getClientsByType } = require('./downloadClients');
/**
* Legacy function for backward compatibility
* Returns all torrents from all qBittorrent instances
*/
async function getAllTorrents() {
const clients = getClients();
if (clients.length === 0) {
try {
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 [];
}
const results = await Promise.all(
clients.map(client => client.getTorrents().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
return [];
}))
);
const allTorrents = results.flat();
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
return allTorrents;
}
/**
* Legacy function for backward compatibility
*/
function getClients() {
logToFile('[qBittorrent] getClients() called - delegating to new system');
return []; // Not used in new system
}
function formatBytes(bytes) {
@@ -187,6 +102,8 @@ function mapTorrentToDownload(torrent) {
return {
type: 'torrent',
title: torrent.name,
client: 'qbittorrent',
instanceId: torrent.instanceId,
instanceName: torrent.instanceName,
status: status,
progress: progress.toFixed(1),
@@ -204,12 +121,13 @@ function mapTorrentToDownload(torrent) {
category: torrent.category,
tags: torrent.tags,
savePath: torrent.content_path || torrent.save_path || null,
addedOn: torrent.added_on || null,
qbittorrent: true
};
}
module.exports = {
getTorrents: getAllTorrents,
getAllTorrents,
getClients,
mapTorrentToDownload,
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)
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)
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Persistent token store backed by a JSON file.
*
+41 -11
View File
@@ -38,10 +38,24 @@ tests/
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
│ └── dashboard.test.js # Pure helper functions: getCoverArt, extractAllTags,
│ # extractUserTag, sanitizeTagLabel, tagMatchesUser,
│ # getImportIssues, getSonarrLink, getRadarrLink,
│ # canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges
└── integration/
├── 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
├── dashboard.test.js # GET /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll,
│ # paused queue, history, importIssues), GET /status,
│ # GET /webhook-metrics, GET /cover-art, POST /blocklist-search
├── emby.test.js # GET /sessions, /users, /users/:id, /session/:id/user
└── arrRoutes.test.js # Sonarr + Radarr: queue, history, series/movies, notifications
# CRUD, /test, /schema, /sofarr-webhook (create + update)
# SABnzbd: queue, history
```
## Key design decisions
@@ -54,14 +68,30 @@ tests/
## Coverage targets
The tested files meet these per-file minimums (enforced in CI):
Global thresholds (enforced in CI via `vitest.config.js`):
| File | Lines | Branches |
|---|---|---|
| `server/app.js` | 85% | 65% |
| `server/routes/auth.js` | 85% | 70% |
| `server/middleware/requireAuth.js` | 75% | 80% |
| `server/utils/sanitizeError.js` | 60% | — |
| `server/utils/config.js` | 50% | 55% |
| Metric | Threshold |
|---|---|
| Statements | 55% |
| Functions | 55% |
| Branches | 40% |
| Lines | 55% |
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
Notable per-file coverage after the current suite:
| File | Lines | Branches | Notes |
|---|---|---|---|
| `server/app.js` | ~92% | ~71% | |
| `server/routes/auth.js` | ~88% | ~78% | |
| `server/routes/dashboard.js` | ~42% | ~25% | SSE `/stream` endpoint intentionally untested |
| `server/routes/emby.js` | 100% | 100% | |
| `server/routes/radarr.js` | ~87% | ~77% | |
| `server/routes/sonarr.js` | ~89% | ~82% | |
| `server/routes/sabnzbd.js` | 100% | 100% | |
| `server/routes/webhook.js` | ~85% | ~79% | |
| `server/middleware/requireAuth.js` | ~92% | ~81% | |
| `server/middleware/verifyCsrf.js` | 100% | 80% | |
| `server/utils/sanitizeError.js` | 100% | 75% | |
| `server/utils/config.js` | ~70% | ~58% | |
`poller.js` (background polling engine) and the SSE `/stream` endpoint in `dashboard.js` require time-based behaviour and long-lived HTTP connections; they remain tracked as future test coverage work.
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/downloads.js
*
* Verifies DOM rendering functions for tag badges and client logos.
* Uses jsdom to create and assert DOM structure.
*/
import { renderTagBadges } from '../../../client/src/ui/downloads.js';
describe('renderTagBadges', () => {
it('returns empty fragment when showAll is false and no matchedUserTag', () => {
const result = renderTagBadges([], false, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('returns empty fragment when tagBadges is empty', () => {
const result = renderTagBadges([], true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('renders single matched badge when matchedUserTag is provided', () => {
const result = renderTagBadges([], false, 'user1');
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge');
expect(badge.textContent).toBe('user1');
});
it('renders unmatched badges when showAll is true', () => {
const tagBadges = [{ label: 'tag1', matchedUser: null }];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge unmatched');
expect(badge.textContent).toBe('tag1');
});
it('renders matched badges when showAll is true', () => {
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge');
expect(badge.textContent).toBe('user1');
});
it('renders multiple badges in correct order (unmatched first)', () => {
const tagBadges = [
{ label: 'tag1', matchedUser: 'user1' },
{ label: 'tag2', matchedUser: null }
];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(2);
expect(result.childNodes[0].textContent).toBe('tag2');
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
expect(result.childNodes[1].textContent).toBe('user1');
expect(result.childNodes[1].className).toBe('download-user-badge');
});
it('handles mixed matched and unmatched badges', () => {
const tagBadges = [
{ label: 'tag1', matchedUser: null },
{ label: 'tag2', matchedUser: 'user2' },
{ label: 'tag3', matchedUser: null }
];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(3);
// Unmatched badges come first
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
expect(result.childNodes[0].textContent).toBe('tag1');
expect(result.childNodes[1].className).toBe('download-user-badge unmatched');
expect(result.childNodes[1].textContent).toBe('tag3');
// Matched badges come after
expect(result.childNodes[2].className).toBe('download-user-badge');
expect(result.childNodes[2].textContent).toBe('user2');
});
it('prefers matchedUserTag over tagBadges when showAll is false', () => {
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
const result = renderTagBadges(tagBadges, false, 'override');
expect(result.childNodes.length).toBe(1);
expect(result.childNodes[0].textContent).toBe('override');
});
it('handles null tagBadges gracefully', () => {
const result = renderTagBadges(null, true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('handles undefined tagBadges gracefully', () => {
const result = renderTagBadges(undefined, true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
});
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/utils/format.js
*
* Verifies formatting utilities for sizes, speeds, dates, and HTML escaping.
* These are pure functions that handle edge cases like null, zero, and large numbers.
*/
import { formatSize, formatSpeed, formatDate, formatTimeAgo, escapeHtml } from '../../../client/src/utils/format.js';
describe('formatSize', () => {
it('returns N/A for null/undefined', () => {
expect(formatSize(null)).toBe('N/A');
expect(formatSize(undefined)).toBe('N/A');
});
it('returns string as-is when already formatted', () => {
expect(formatSize('21.5 GB')).toBe('21.5 GB');
});
it('formats bytes correctly', () => {
expect(formatSize(512)).toBe('512 B');
});
it('formats kilobytes correctly', () => {
expect(formatSize(1024)).toBe('1 KB');
});
it('formats megabytes correctly', () => {
expect(formatSize(1024 * 1024)).toBe('1 MB');
});
it('formats gigabytes correctly', () => {
expect(formatSize(1024 * 1024 * 1024)).toBe('1 GB');
});
it('handles zero', () => {
expect(formatSize(0)).toBe('N/A');
});
});
describe('formatSpeed', () => {
it('returns 0 B/s for zero', () => {
expect(formatSpeed(0)).toBe('0 B/s');
});
it('returns 0 B/s for null/undefined', () => {
expect(formatSpeed(null)).toBe('0 B/s');
expect(formatSpeed(undefined)).toBe('0 B/s');
});
it('formats bytes per second correctly', () => {
expect(formatSpeed(512)).toBe('512.00 B/s');
});
it('formats kilobytes per second correctly', () => {
expect(formatSpeed(1024)).toBe('1.00 KB/s');
});
it('formats megabytes per second correctly', () => {
expect(formatSpeed(1024 * 1024)).toBe('1.00 MB/s');
});
it('handles large numbers', () => {
expect(formatSpeed(1024 * 1024 * 1024)).toBe('1.00 GB/s');
});
});
describe('formatDate', () => {
it('returns N/A for null/undefined', () => {
expect(formatDate(null)).toBe('N/A');
expect(formatDate(undefined)).toBe('N/A');
});
it('formats valid date string', () => {
const dateStr = '2024-01-15T10:30:00Z';
const result = formatDate(dateStr);
expect(result).toBeTruthy();
expect(result).not.toBe('N/A');
});
});
describe('formatTimeAgo', () => {
it('returns Never for null/undefined', () => {
expect(formatTimeAgo(null)).toBe('Never');
expect(formatTimeAgo(undefined)).toBe('Never');
});
it('returns seconds ago for recent timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 30000)).toBe('30s ago');
});
it('returns minutes ago for older timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 5)).toBe('5m ago');
});
it('returns hours ago for hours-old timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 60 * 3)).toBe('3h ago');
});
it('returns days ago for day-old timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 60 * 24 * 2)).toBe('2d ago');
});
});
describe('escapeHtml', () => {
it('escapes HTML special characters', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert("xss")&lt;/script&gt;');
});
it('escapes quotes', () => {
expect(escapeHtml('"test"')).toBe('"test"');
});
it('handles empty string', () => {
expect(escapeHtml('')).toBe('');
});
it('handles normal text without special chars', () => {
expect(escapeHtml('normal text')).toBe('normal text');
});
});
+899
View File
@@ -0,0 +1,899 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for server/routes/sonarr.js, server/routes/radarr.js,
* and server/routes/sabnzbd.js.
*
* Covers:
* Sonarr: queue, history, series, series/:id, notifications CRUD,
* notifications/test, notifications/schema, sofarr-webhook (create + update)
* Radarr: same set, movies instead of series
* SABnzbd: queue, history
*
* All routes require auth; state-changing requests (POST/PUT/DELETE) must also
* carry the CSRF token issued by GET /api/auth/csrf (verifyCsrf middleware).
*/
import request from 'supertest';
import nock from 'nock';
import { createApp } from '../../server/app.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EMBY_BASE = 'https://emby.test';
const SONARR_BASE = 'https://sonarr.test';
const RADARR_BASE = 'https://radarr.test';
const SABNZBD_BASE = 'https://sabnzbd.test';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const SONARR_QUEUE = { records: [{ id: 1, title: 'Show.S01E01', seriesId: 10 }] };
const SONARR_HISTORY = { records: [{ id: 100, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01' }] };
const SONARR_SERIES_LIST = [{ id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }];
const SONARR_SERIES_ITEM = { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] };
const SONARR_NOTIFICATIONS = [{ id: 5, name: 'Plex', implementation: 'Plex' }];
const SONARR_NOTIF_ITEM = { id: 5, name: 'Plex', implementation: 'Plex', fields: [] };
const RADARR_QUEUE = { records: [{ id: 2, title: 'Movie.2024.1080p', movieId: 20 }] };
const RADARR_HISTORY = { records: [{ id: 200, eventType: 'downloadFolderImported', sourceTitle: 'Movie.2024.1080p' }] };
const RADARR_MOVIES_LIST = [{ id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }];
const RADARR_MOVIE_ITEM = { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] };
const RADARR_NOTIFICATIONS = [{ id: 7, name: 'Plex', implementation: 'Plex' }];
const RADARR_NOTIF_ITEM = { id: 7, name: 'Plex', implementation: 'Plex', fields: [] };
const SAB_QUEUE_RESP = { queue: { status: 'Downloading', slots: [{ filename: 'Show.S01E01', percentage: '50' }] } };
const SAB_HISTORY_RESP = { history: { slots: [{ name: 'Show.S01E01.Done', status: 'Completed' }] } };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function interceptLogin() {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
}
async function loginAs(app) {
interceptLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: 'pw' });
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
}
async function getSessionWithCsrf(app) {
const { cookies, csrf } = await loginAs(app);
// Obtain a fresh csrf cookie as well (login already sets one, but keep consistent)
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
return { cookies, csrf, csrfCookie };
}
// Build the Cookie header for state-changing requests: session + csrf cookies
function joinCookies(sessionCookies, csrfCookie) {
const all = Array.isArray(sessionCookies) ? [...sessionCookies] : [sessionCookies];
if (csrfCookie && !all.includes(csrfCookie)) all.push(csrfCookie);
return all.join('; ');
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
beforeAll(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_URL = SONARR_BASE;
process.env.SONARR_API_KEY = 'sk';
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
process.env.RADARR_URL = RADARR_BASE;
process.env.RADARR_API_KEY = 'rk';
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
process.env.SABNZBD_URL = SABNZBD_BASE;
process.env.SABNZBD_API_KEY = 'sabkey';
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
process.env.SOFARR_WEBHOOK_SECRET = 'webhook-secret-abc';
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SONARR_URL;
delete process.env.SONARR_API_KEY;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_URL;
delete process.env.RADARR_API_KEY;
delete process.env.RADARR_INSTANCES;
delete process.env.SABNZBD_URL;
delete process.env.SABNZBD_API_KEY;
delete process.env.SOFARR_BASE_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
});
afterEach(() => {
nock.cleanAll();
});
// ===========================================================================
// SONARR ROUTES
// ===========================================================================
describe('Sonarr routes', () => {
describe('GET /api/sonarr/queue', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/sonarr/queue');
expect(res.status).toBe(401);
});
it('proxies Sonarr queue', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/queue').reply(200, SONARR_QUEUE);
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.records).toBeDefined();
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/queue/i);
});
});
describe('GET /api/sonarr/history', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/sonarr/history');
expect(res.status).toBe(401);
});
it('proxies Sonarr history with default pageSize', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/history').query(true).reply(200, SONARR_HISTORY);
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.records).toBeDefined();
});
it('passes through custom pageSize', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/history').query({ pageSize: '100' }).reply(200, SONARR_HISTORY);
const res = await request(app).get('/api/sonarr/history?pageSize=100').set('Cookie', cookies);
expect(res.status).toBe(200);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/sonarr/series', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/sonarr/series');
expect(res.status).toBe(401);
});
it('proxies series list', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/series').reply(200, SONARR_SERIES_LIST);
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/series').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/sonarr/series/:id', () => {
it('proxies individual series', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/series/10').reply(200, SONARR_SERIES_ITEM);
const res = await request(app).get('/api/sonarr/series/10').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.title).toBe('My Show');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/series/999').replyWithError('not found');
const res = await request(app).get('/api/sonarr/series/999').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/sonarr/notifications', () => {
it('returns 503 when no Sonarr instance configured', async () => {
const app = createApp({ skipRateLimits: true });
// Temporarily clear instances
const saved = process.env.SONARR_INSTANCES;
delete process.env.SONARR_INSTANCES;
delete process.env.SONARR_URL;
delete process.env.SONARR_API_KEY;
interceptLogin();
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
const cookies = loginRes.headers['set-cookie'];
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(503);
process.env.SONARR_INSTANCES = saved;
process.env.SONARR_URL = SONARR_BASE;
process.env.SONARR_API_KEY = 'sk';
});
it('proxies notifications list', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/notification').reply(200, SONARR_NOTIFICATIONS);
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/sonarr/notifications/:id', () => {
it('proxies a single notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/notification/5').reply(200, SONARR_NOTIF_ITEM);
const res = await request(app).get('/api/sonarr/notifications/5').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Plex');
});
});
describe('POST /api/sonarr/notifications', () => {
it('returns 403 (CSRF missing) without auth', async () => {
// verifyCsrf fires before requireAuth on POST routes — no CSRF → 403
const app = createApp({ skipRateLimits: true });
const res = await request(app).post('/api/sonarr/notifications').send({});
expect(res.status).toBe(403);
});
it('creates a notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 99, name: 'New' });
const res = await request(app)
.post('/api/sonarr/notifications')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ name: 'New' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('New');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).post('/api/v3/notification').replyWithError('ECONNREFUSED');
const res = await request(app)
.post('/api/sonarr/notifications')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ name: 'New' });
expect(res.status).toBe(500);
});
});
describe('PUT /api/sonarr/notifications/:id', () => {
it('updates a notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).put('/api/v3/notification/5').reply(200, { id: 5, name: 'Updated' });
const res = await request(app)
.put('/api/sonarr/notifications/5')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 5, name: 'Updated' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Updated');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).put('/api/v3/notification/5').replyWithError('ECONNREFUSED');
const res = await request(app)
.put('/api/sonarr/notifications/5')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 5 });
expect(res.status).toBe(500);
});
});
describe('DELETE /api/sonarr/notifications/:id', () => {
it('deletes a notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).delete('/api/v3/notification/5').reply(200, {});
const res = await request(app)
.delete('/api/sonarr/notifications/5')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf);
expect(res.status).toBe(200);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).delete('/api/v3/notification/5').replyWithError('ECONNREFUSED');
const res = await request(app)
.delete('/api/sonarr/notifications/5')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf);
expect(res.status).toBe(500);
});
});
describe('POST /api/sonarr/notifications/test', () => {
it('returns 503 when no Sonarr instance configured', async () => {
const app = createApp({ skipRateLimits: true });
const saved = process.env.SONARR_INSTANCES;
const savedUrl = process.env.SONARR_URL;
const savedKey = process.env.SONARR_API_KEY;
delete process.env.SONARR_INSTANCES;
delete process.env.SONARR_URL;
delete process.env.SONARR_API_KEY;
interceptLogin();
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
const cookies = loginRes.headers['set-cookie'];
const csrf = loginRes.body.csrfToken;
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
const res = await request(app)
.post('/api/sonarr/notifications/test')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(503);
process.env.SONARR_INSTANCES = saved;
process.env.SONARR_URL = savedUrl;
process.env.SONARR_API_KEY = savedKey;
});
it('tests a notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).post('/api/v3/notification/test').reply(200, {});
const res = await request(app)
.post('/api/sonarr/notifications/test')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 5 });
expect(res.status).toBe(200);
});
it('returns 500 when test fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
const res = await request(app)
.post('/api/sonarr/notifications/test')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 5 });
expect(res.status).toBe(500);
});
});
describe('GET /api/sonarr/notifications/schema', () => {
it('proxies the schema', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SONARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
const res = await request(app).get('/api/sonarr/notifications/schema').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});
describe('POST /api/sonarr/notifications/sofarr-webhook', () => {
it('returns 400 when SOFARR_BASE_URL is not configured', async () => {
const app = createApp({ skipRateLimits: true });
const saved = process.env.SOFARR_BASE_URL;
delete process.env.SOFARR_BASE_URL;
interceptLogin();
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
const cookies = loginRes.headers['set-cookie'];
const csrf = loginRes.body.csrfToken;
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
const res = await request(app)
.post('/api/sonarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/SOFARR_BASE_URL/);
process.env.SOFARR_BASE_URL = saved;
});
it('creates a new webhook notification when none exists', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 10, name: 'Sofarr' });
const res = await request(app)
.post('/api/sonarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(200);
expect(res.body.name).toBe('Sofarr');
});
it('updates an existing Sofarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE)
.get('/api/v3/notification')
.reply(200, [{ id: 10, name: 'Sofarr', implementation: 'Webhook' }]);
nock(SONARR_BASE)
.put('/api/v3/notification/10')
.reply(200, { id: 10, name: 'Sofarr' });
const res = await request(app)
.post('/api/sonarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(200);
expect(res.body.name).toBe('Sofarr');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
const res = await request(app)
.post('/api/sonarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(500);
});
});
});
// ===========================================================================
// RADARR ROUTES
// ===========================================================================
describe('Radarr routes', () => {
describe('GET /api/radarr/queue', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/radarr/queue');
expect(res.status).toBe(401);
});
it('proxies Radarr queue', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/queue').reply(200, RADARR_QUEUE);
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.records).toBeDefined();
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/history', () => {
it('proxies Radarr history', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/history').query(true).reply(200, RADARR_HISTORY);
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
expect(res.status).toBe(200);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/movies', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/radarr/movies');
expect(res.status).toBe(401);
});
it('proxies movies list', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/movie').reply(200, RADARR_MOVIES_LIST);
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/movie').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/movies/:id', () => {
it('proxies a single movie', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/movie/20').reply(200, RADARR_MOVIE_ITEM);
const res = await request(app).get('/api/radarr/movies/20').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.title).toBe('My Movie');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/movie/999').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/radarr/movies/999').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/notifications', () => {
it('returns 503 when no Radarr instance configured', async () => {
const app = createApp({ skipRateLimits: true });
const saved = process.env.RADARR_INSTANCES;
const savedUrl = process.env.RADARR_URL;
const savedKey = process.env.RADARR_API_KEY;
delete process.env.RADARR_INSTANCES;
delete process.env.RADARR_URL;
delete process.env.RADARR_API_KEY;
interceptLogin();
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
const cookies = loginRes.headers['set-cookie'];
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(503);
process.env.RADARR_INSTANCES = saved;
process.env.RADARR_URL = savedUrl;
process.env.RADARR_API_KEY = savedKey;
});
it('proxies notifications list', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, RADARR_NOTIFICATIONS);
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
describe('POST /api/radarr/notifications', () => {
it('creates a Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 88, name: 'New' });
const res = await request(app)
.post('/api/radarr/notifications')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ name: 'New' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('New');
});
});
describe('PUT /api/radarr/notifications/:id', () => {
it('updates a Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).put('/api/v3/notification/7').reply(200, { id: 7, name: 'Updated' });
const res = await request(app)
.put('/api/radarr/notifications/7')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 7, name: 'Updated' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Updated');
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).put('/api/v3/notification/7').replyWithError('ECONNREFUSED');
const res = await request(app)
.put('/api/radarr/notifications/7')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 7 });
expect(res.status).toBe(500);
});
});
describe('DELETE /api/radarr/notifications/:id', () => {
it('deletes a Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).delete('/api/v3/notification/7').reply(200, {});
const res = await request(app)
.delete('/api/radarr/notifications/7')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf);
expect(res.status).toBe(200);
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).delete('/api/v3/notification/7').replyWithError('ECONNREFUSED');
const res = await request(app)
.delete('/api/radarr/notifications/7')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf);
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/notifications/:id', () => {
it('proxies a single Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/notification/7').reply(200, RADARR_NOTIF_ITEM);
const res = await request(app).get('/api/radarr/notifications/7').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Plex');
});
});
describe('POST /api/radarr/notifications/test', () => {
it('tests a Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).post('/api/v3/notification/test').reply(200, {});
const res = await request(app)
.post('/api/radarr/notifications/test')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 7 });
expect(res.status).toBe(200);
});
it('returns 500 when test fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
const res = await request(app)
.post('/api/radarr/notifications/test')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({ id: 7 });
expect(res.status).toBe(500);
});
});
describe('GET /api/radarr/notifications/schema', () => {
it('proxies the Radarr notification schema', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(RADARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
const res = await request(app).get('/api/radarr/notifications/schema').set('Cookie', cookies);
expect(res.status).toBe(200);
});
});
describe('POST /api/radarr/notifications/sofarr-webhook', () => {
it('creates a new Radarr webhook when none exists', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 20, name: 'Sofarr' });
const res = await request(app)
.post('/api/radarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(200);
expect(res.body.name).toBe('Sofarr');
});
it('updates an existing Sofarr Radarr notification', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE)
.get('/api/v3/notification')
.reply(200, [{ id: 20, name: 'Sofarr', implementation: 'Webhook' }]);
nock(RADARR_BASE)
.put('/api/v3/notification/20')
.reply(200, { id: 20, name: 'Sofarr' });
const res = await request(app)
.post('/api/radarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(200);
});
it('returns 400 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
const app = createApp({ skipRateLimits: true });
const saved = process.env.SOFARR_WEBHOOK_SECRET;
delete process.env.SOFARR_WEBHOOK_SECRET;
interceptLogin();
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
const cookies = loginRes.headers['set-cookie'];
const csrf = loginRes.body.csrfToken;
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
const res = await request(app)
.post('/api/radarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/SOFARR_WEBHOOK_SECRET/);
process.env.SOFARR_WEBHOOK_SECRET = saved;
});
it('returns 500 on upstream failure', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
const res = await request(app)
.post('/api/radarr/notifications/sofarr-webhook')
.set('Cookie', joinCookies(cookies, csrfCookie))
.set('X-CSRF-Token', csrf)
.send({});
expect(res.status).toBe(500);
});
});
});
// ===========================================================================
// SABNZBD ROUTES
// ===========================================================================
describe('SABnzbd routes', () => {
describe('GET /api/sabnzbd/queue', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/sabnzbd/queue');
expect(res.status).toBe(401);
});
it('proxies SABnzbd queue', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SABNZBD_BASE)
.get('/api')
.query({ mode: 'queue', apikey: 'sabkey', output: 'json' })
.reply(200, SAB_QUEUE_RESP);
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.queue).toBeDefined();
expect(res.body.queue.status).toBe('Downloading');
});
it('returns 500 when SABnzbd is unreachable', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SABNZBD_BASE)
.get('/api')
.query(true)
.replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/queue/i);
});
});
describe('GET /api/sabnzbd/history', () => {
it('returns 401 when unauthenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/sabnzbd/history');
expect(res.status).toBe(401);
});
it('proxies SABnzbd history with default limit', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SABNZBD_BASE)
.get('/api')
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '50' })
.reply(200, SAB_HISTORY_RESP);
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toBeDefined();
});
it('passes through custom limit', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SABNZBD_BASE)
.get('/api')
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '100' })
.reply(200, SAB_HISTORY_RESP);
const res = await request(app).get('/api/sabnzbd/history?limit=100').set('Cookie', cookies);
expect(res.status).toBe(200);
});
it('returns 500 when SABnzbd is unreachable', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock(SABNZBD_BASE)
.get('/api')
.query(true)
.replyWithError('ECONNREFUSED');
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/history/i);
});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for authentication routes.
*
+837
View File
@@ -0,0 +1,837 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for server/routes/dashboard.js
*
* Strategy:
* - createApp({ skipRateLimits: true }) for a real Express instance
* - nock intercepts Emby auth so we can obtain a valid session cookie
* - cache is seeded directly (same technique as history.test.js) so the
* route's cache.get() calls return controlled fixture data without any
* real outbound HTTP to SABnzbd / Sonarr / Radarr / qBittorrent
* - nock is used for outbound axios calls made by the routes themselves
* (cover-art proxy, blocklist-search, status webhook-check)
*
* Covers:
* GET /api/dashboard/user-downloads auth guard, SAB+Sonarr, SAB+Radarr,
* qBittorrent, showAll (admin), empty cache, on-demand poll trigger,
* paused queue speed, error propagation
* GET /api/dashboard/status admin-only guard, shape check
* GET /api/dashboard/webhook-metrics any authenticated user
* GET /api/dashboard/cover-art missing url, non-http scheme, proxy, non-image
* POST /api/dashboard/blocklist-search admin guard, validation, sonarr+radarr paths
*/
import request from 'supertest';
import nock from 'nock';
import { createRequire } from 'module';
import { createApp } from '../../server/app.js';
const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EMBY_BASE = 'https://emby.test';
const SONARR_BASE = 'https://sonarr.test';
const RADARR_BASE = 'https://radarr.test';
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire in a test run
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const EMBY_ADMIN_AUTH = { AccessToken: 'tok-admin', User: { Id: 'uid2', Name: 'admin' } };
const EMBY_ADMIN_USER = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
// Tag id 1 → 'alice', id 2 → 'admin'
const SONARR_TAGS = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
const RADARR_TAGS = [{ id: 10, label: 'alice' }, { id: 11, label: 'admin' }];
const SERIES = {
id: 42,
title: 'My Show',
titleSlug: 'my-show',
tags: [1],
path: '/tv/my-show',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg' }],
_instanceUrl: SONARR_BASE
};
const ADMIN_SERIES = {
id: 43,
title: 'Admin Show',
titleSlug: 'admin-show',
tags: [2],
path: '/tv/admin-show',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/admin-poster.jpg' }],
_instanceUrl: SONARR_BASE
};
const ADMIN_SAB_SLOT = {
filename: 'Admin.Show.S01E01.720p',
nzbname: 'Admin.Show.S01E01.720p',
nzo_id: 'SABnzbd_nzo_admin001',
percentage: '40',
mb: '500',
mbmissing: '300',
size: '500 MB',
status: 'Downloading',
storage: '/downloads/Admin.Show.S01E01.720p',
timeleft: '0:08:00'
};
const ADMIN_SONARR_QUEUE_RECORD = {
id: 1002,
title: 'Admin.Show.S01E01.720p',
seriesId: 43,
series: ADMIN_SERIES,
episodeId: 502,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: SONARR_BASE,
_instanceKey: 'sonarr-api-key'
};
const MOVIE = {
id: 99,
title: 'My Movie',
titleSlug: 'my-movie-2024',
tags: [10],
path: '/movies/my-movie',
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/movie-poster.jpg' }],
_instanceUrl: RADARR_BASE
};
const SAB_QUEUE_SLOT = {
filename: 'My.Show.S01E01.720p',
nzbname: 'My.Show.S01E01.720p',
nzo_id: 'SABnzbd_nzo_abc123',
percentage: '55',
mb: '700',
mbmissing: '315',
size: '700 MB',
status: 'Downloading',
storage: '/downloads/My.Show.S01E01.720p',
timeleft: '0:10:00'
};
const SAB_MOVIE_SLOT = {
filename: 'My.Movie.2024.1080p',
nzbname: 'My.Movie.2024.1080p',
nzo_id: 'SABnzbd_nzo_xyz999',
percentage: '80',
mb: '4000',
mbmissing: '800',
size: '4 GB',
status: 'Downloading',
timeleft: '0:05:00'
};
const SONARR_QUEUE_RECORD = {
id: 1001,
title: 'My.Show.S01E01.720p',
seriesId: 42,
series: SERIES,
episodeId: 501,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: SONARR_BASE,
_instanceKey: 'sonarr-api-key'
};
const RADARR_QUEUE_RECORD = {
id: 2001,
title: 'My.Movie.2024.1080p',
movieId: 99,
movie: MOVIE,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'radarr-api-key'
};
const QBIT_TORRENT = {
hash: 'abc123def456',
name: 'My.Show.S01E01.720p',
state: 'downloading',
progress: 0.55,
size: 734003200,
downloaded: 403701760,
uploadSpeed: 0,
downloadSpeed: 1024000,
eta: 300,
savePath: '/downloads/torrents/',
addedOn: Date.now() / 1000 - 7200
};
// ---------------------------------------------------------------------------
// Cache seeding helpers
// ---------------------------------------------------------------------------
function seedEmptyCache() {
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', [], CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
}
function seedSabSonarrCache() {
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
}
function seedSabRadarrCache() {
cache.set('poll:sab-queue', { slots: [SAB_MOVIE_SLOT], status: 'Downloading', speed: '5 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
}
function seedQbittorrentSonarrCache() {
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [QBIT_TORRENT], CACHE_TTL);
}
function invalidatePollCache() {
const keys = [
'poll:sab-queue', 'poll:sab-history',
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
'poll:qbittorrent'
];
for (const k of keys) cache.invalidate(k);
}
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function interceptEmbyLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
}
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
interceptEmbyLogin(userBody, authBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username: userBody.Name, password: 'pw' });
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
}
// CSRF token must be sent with state-changing (POST) requests that go through
// the verifyCsrf middleware. GET requests under /api/dashboard do not need it.
async function csrfHeaders(app) {
const csrfRes = await request(app).get('/api/auth/csrf');
const token = csrfRes.body.csrfToken;
const cookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
return { token, cookie };
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
beforeAll(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
beforeEach(() => {
seedEmptyCache();
});
afterEach(() => {
nock.cleanAll();
invalidatePollCache();
cache.invalidate('emby:users');
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/user-downloads
// ---------------------------------------------------------------------------
describe('GET /api/dashboard/user-downloads', () => {
describe('authentication', () => {
it('returns 401 when not logged in', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/user-downloads');
expect(res.status).toBe(401);
});
});
describe('empty cache', () => {
it('returns empty downloads array for authenticated user', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedEmptyCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.user).toBe('alice');
expect(res.body.isAdmin).toBe(false);
expect(res.body.downloads).toEqual([]);
});
});
describe('SABnzbd + Sonarr queue matching', () => {
it('returns a series download when SAB slot title matches Sonarr queue record', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const downloads = res.body.downloads;
expect(downloads.length).toBeGreaterThanOrEqual(1);
const dl = downloads[0];
expect(dl.type).toBe('series');
expect(dl.seriesName).toBe('My Show');
expect(dl.coverArt).toBe('https://img.test/poster.jpg');
});
it('includes admin-only fields when user is admin', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Seed a SAB slot + Sonarr record tagged for 'admin' so the admin user gets a result
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrQueueId).toBe(1002);
expect(dl.arrType).toBe('sonarr');
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
expect(dl.downloadPath).toBeDefined();
});
it('does not include admin-only fields for non-admin user', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrQueueId).toBeUndefined();
expect(dl.arrType).toBeUndefined();
});
it('does not return downloads tagged for a different user', async () => {
const app = createApp({ skipRateLimits: true });
// Login as 'bob' — series is tagged 'alice'
interceptEmbyLogin({ Id: 'uid-bob', Name: 'bob', Policy: { IsAdministrator: false } }, { AccessToken: 'tok-bob', User: { Id: 'uid-bob', Name: 'bob' } });
const res1 = await request(app)
.post('/api/auth/login')
.send({ username: 'bob', password: 'pw' });
const bobCookies = res1.headers['set-cookie'];
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', bobCookies);
expect(res.status).toBe(200);
expect(res.body.downloads).toEqual([]);
});
});
describe('SABnzbd + Radarr queue matching', () => {
it('returns a movie download when SAB slot title matches Radarr queue record', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabRadarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'movie');
expect(dl).toBeDefined();
expect(dl.movieName).toBe('My Movie');
});
});
describe('qBittorrent + Sonarr queue matching', () => {
it('returns a series download from a qBittorrent torrent', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedQbittorrentSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.seriesName).toBe('My Show');
});
});
describe('paused queue', () => {
it('reports Paused status when SAB queue is paused', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Paused', speed: '0' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
if (dl) {
expect(dl.status).toBe('Paused');
expect(dl.speed).toBe(0);
}
});
});
describe('showAll (admin)', () => {
it('returns downloads for all tagged users when showAll=true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
seedSabSonarrCache();
// Stub Emby users list used by getEmbyUsers()
nock(EMBY_BASE)
.get('/Users')
.reply(200, [{ Name: 'alice' }, { Name: 'bob' }]);
const res = await request(app)
.get('/api/dashboard/user-downloads?showAll=true')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.isAdmin).toBe(true);
// tagBadges should be present on results when showAll is active
const dl = res.body.downloads.find(d => d.allTags && d.allTags.length > 0);
if (dl) {
expect(Array.isArray(dl.tagBadges)).toBe(true);
}
});
it('non-admin cannot use showAll — still filtered to their own tags', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
const res = await request(app)
.get('/api/dashboard/user-downloads?showAll=true')
.set('Cookie', cookies);
expect(res.status).toBe(200);
// Non-admin: showAll has no effect, tagBadges must be absent
const dl = res.body.downloads[0];
if (dl) expect(dl.tagBadges).toBeUndefined();
});
});
describe('refreshRate tracking', () => {
it('accepts refreshRate query parameter without error', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedEmptyCache();
const res = await request(app)
.get('/api/dashboard/user-downloads?refreshRate=10000')
.set('Cookie', cookies);
expect(res.status).toBe(200);
});
});
describe('SABnzbd history matching', () => {
it('returns a series download matched from SAB history + Sonarr history', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const historySlot = {
name: 'My.Show.S01E02.720p',
status: 'Completed',
size: '700 MB',
completed_time: Math.floor(Date.now() / 1000) - 3600
};
const sonarrHistoryRecord = {
id: 9001,
sourceTitle: 'My.Show.S01E02.720p',
seriesId: 42,
series: { ...SERIES },
episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' }
};
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [historySlot] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [sonarrHistoryRecord] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.seriesName).toBe('My Show');
});
});
describe('import issues', () => {
it('includes importIssues when Sonarr record has warning status', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const problemRecord = {
...SONARR_QUEUE_RECORD,
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['No suitable video file found'] }]
};
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [problemRecord] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const dl = res.body.downloads.find(d => d.importIssues);
expect(dl).toBeDefined();
expect(dl.importIssues).toContain('No suitable video file found');
expect(dl.canBlocklist).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// GET /api/status
// ---------------------------------------------------------------------------
describe('GET /api/status', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/status');
expect(res.status).toBe(401);
});
it('returns 403 for non-admin users', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
// Status route fetches Sonarr/Radarr notifications — intercept them
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/admin/i);
});
it('returns server/cache/polling/webhook stats for admin', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.server).toBeDefined();
expect(typeof res.body.server.uptimeSeconds).toBe('number');
expect(typeof res.body.server.nodeVersion).toBe('string');
expect(res.body.cache).toBeDefined();
expect(res.body.polling).toBeDefined();
expect(res.body.webhooks).toBeDefined();
});
it('handles Sonarr/Radarr notification check failures gracefully', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeNull();
expect(res.body.webhooks.radarr).toBeNull();
});
it('reports webhook configured=true when Sofarr notification exists', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
nock(SONARR_BASE)
.get('/api/v3/notification')
.reply(200, [{ name: 'Sofarr', implementation: 'Webhook' }]);
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
const res = await request(app)
.get('/api/status')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.webhooks.sonarr).toBeDefined();
expect(res.body.webhooks.sonarr.enabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/cover-art
// ---------------------------------------------------------------------------
describe('GET /api/dashboard/cover-art', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/dashboard/cover-art?url=https://img.test/poster.jpg');
expect(res.status).toBe(401);
});
it('returns 400 when url parameter is missing', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/missing url/i);
});
it('returns 400 for an invalid URL', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art?url=not-a-url')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid url/i);
});
it('returns 400 for non-http/https scheme', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/dashboard/cover-art?url=ftp://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/http/i);
});
it('returns 400 when remote URL is not an image', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/notanimage.html')
.reply(200, '<html/>', { 'content-type': 'text/html' });
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/notanimage.html')
.set('Cookie', cookies);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not an image/i);
});
it('returns 502 when remote image fetch fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/poster.jpg')
.replyWithError('connection refused');
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(502);
});
it('proxies an image and sets correct headers', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
nock('https://img.test')
.get('/poster.jpg')
.reply(200, Buffer.from('FAKEJPEG'), { 'content-type': 'image/jpeg' });
const res = await request(app)
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
expect(res.headers['cache-control']).toMatch(/max-age=86400/);
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
});
// ---------------------------------------------------------------------------
// POST /api/dashboard/blocklist-search
// ---------------------------------------------------------------------------
describe('POST /api/dashboard/blocklist-search', () => {
async function getAuthHeaders(app, userBody = EMBY_ADMIN_USER, authBody = EMBY_ADMIN_AUTH) {
const { cookies, csrf } = await loginAs(app, userBody, authBody);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
return { cookies, csrfCookie, csrf };
}
it('returns 403 (CSRF missing) when not authenticated', async () => {
// verifyCsrf middleware fires before requireAuth for POST routes;
// an unauthenticated POST without CSRF headers gets 403, not 401.
const app = createApp({ skipRateLimits: true });
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
});
it('returns 403 for non-admin user', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/admin/i);
});
it('returns 400 when required fields are missing', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/missing/i);
});
it('returns 400 for invalid arrType', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1, arrType: 'invalid', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/sonarr or radarr/i);
});
it('calls Sonarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
nock(RADARR_BASE)
.delete('/api/v3/queue/2001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(RADARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query(true)
.replyWithError('connection refused');
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
});
});
+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);
});
});
});
+260
View File
@@ -0,0 +1,260 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for server/routes/emby.js
*
* All four endpoints are covered:
* GET /api/emby/sessions
* GET /api/emby/users
* GET /api/emby/users/:id
* GET /api/emby/session/:sessionId/user
*
* For each: auth guard (401), happy path, and upstream failure (500).
* No CSRF token is needed all routes are read-only GETs.
*/
import request from 'supertest';
import nock from 'nock';
import { createApp } from '../../server/app.js';
const EMBY_BASE = 'https://emby.test';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
const EMBY_SESSIONS = [
{ Id: 'sess-001', UserId: 'uid1', UserName: 'alice', Client: 'Emby Web', DeviceName: 'Chrome' },
{ Id: 'sess-002', UserId: 'uid2', UserName: 'bob', Client: 'Emby iOS', DeviceName: 'iPhone' }
];
const EMBY_USERS_LIST = [
{ Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } },
{ Id: 'uid2', Name: 'bob', Policy: { IsAdministrator: false } }
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function interceptLogin() {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
}
async function loginAs(app) {
interceptLogin();
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'alice', password: 'pw' });
return res.headers['set-cookie'];
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
beforeAll(() => {
process.env.EMBY_URL = EMBY_BASE;
process.env.EMBY_API_KEY = 'emby-api-key';
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.EMBY_API_KEY;
});
afterEach(() => {
nock.cleanAll();
});
// ---------------------------------------------------------------------------
// GET /api/emby/sessions
// ---------------------------------------------------------------------------
describe('GET /api/emby/sessions', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/emby/sessions');
expect(res.status).toBe(401);
});
it('proxies Emby sessions list', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Sessions')
.reply(200, EMBY_SESSIONS);
const res = await request(app)
.get('/api/emby/sessions')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBe(2);
expect(res.body[0].Id).toBe('sess-001');
});
it('returns 500 when Emby is unreachable', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Sessions')
.replyWithError('ECONNREFUSED');
const res = await request(app)
.get('/api/emby/sessions')
.set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/sessions/i);
});
});
// ---------------------------------------------------------------------------
// GET /api/emby/users
// ---------------------------------------------------------------------------
describe('GET /api/emby/users', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/emby/users');
expect(res.status).toBe(401);
});
it('proxies Emby users list', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Users')
.reply(200, EMBY_USERS_LIST);
const res = await request(app)
.get('/api/emby/users')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0].Name).toBe('alice');
});
it('returns 500 when Emby is unreachable', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Users')
.replyWithError('ECONNREFUSED');
const res = await request(app)
.get('/api/emby/users')
.set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/users/i);
});
});
// ---------------------------------------------------------------------------
// GET /api/emby/users/:id
// ---------------------------------------------------------------------------
describe('GET /api/emby/users/:id', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/emby/users/uid1');
expect(res.status).toBe(401);
});
it('proxies individual user details', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Users/uid1')
.reply(200, EMBY_USER);
const res = await request(app)
.get('/api/emby/users/uid1')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.Id).toBe('uid1');
expect(res.body.Name).toBe('alice');
});
it('returns 500 when Emby returns an error', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Users/uid-unknown')
.reply(404, { error: 'Not found' });
const res = await request(app)
.get('/api/emby/users/uid-unknown')
.set('Cookie', cookies);
expect(res.status).toBe(500);
});
});
// ---------------------------------------------------------------------------
// GET /api/emby/session/:sessionId/user
// ---------------------------------------------------------------------------
describe('GET /api/emby/session/:sessionId/user', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/emby/session/sess-001/user');
expect(res.status).toBe(401);
});
it('returns the user associated with a session', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Sessions')
.reply(200, EMBY_SESSIONS);
nock(EMBY_BASE)
.get('/Users/uid1')
.reply(200, EMBY_USER);
const res = await request(app)
.get('/api/emby/session/sess-001/user')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.Name).toBe('alice');
});
it('returns 404 when session ID is not found', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Sessions')
.reply(200, EMBY_SESSIONS);
const res = await request(app)
.get('/api/emby/session/sess-nonexistent/user')
.set('Cookie', cookies);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/session not found/i);
});
it('returns 500 when Emby sessions fetch fails', async () => {
const app = createApp({ skipRateLimits: true });
const cookies = await loginAs(app);
nock(EMBY_BASE)
.get('/Sessions')
.replyWithError('ECONNREFUSED');
const res = await request(app)
.get('/api/emby/session/sess-001/user')
.set('Cookie', cookies);
expect(res.status).toBe(500);
expect(res.body.error).toMatch(/session/i);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for health and readiness endpoints.
*
+295
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for GET /api/history/recent
*
@@ -97,6 +98,60 @@ const RADARR_RECORD_IMPORTED = {
movieId: 20
};
// Deduplication fixtures — same episodeId 55, episode 1 failed then imported
const SONARR_RECORD_FAILED_EP55 = {
id: 110,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E01.720p',
date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
quality: { quality: { name: '720p' } },
data: { message: 'Download failed' },
episodeId: 55,
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
const SONARR_RECORD_IMPORTED_EP55 = {
id: 111,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S02E01.720p',
date: new Date().toISOString(), // now (more recent)
quality: { quality: { name: '720p' } },
episodeId: 55,
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// Failed, still failing (hasFile=false) — most recent is a failure with no file
const SONARR_RECORD_FAILED_EP56 = {
id: 112,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E02.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'No seeders' },
episodeId: 56,
episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// Failed but hasFile=true — episode is available, failure is an upgrade attempt
const SONARR_RECORD_FAILED_EP57_HAS_FILE = {
id: 113,
eventType: 'downloadFailed',
sourceTitle: 'Show.S02E03.720p',
date: new Date().toISOString(),
quality: { quality: { name: '720p' } },
data: { message: 'Upgrade failed' },
episodeId: 57,
episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
};
// --- Helpers ---
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
@@ -271,6 +326,63 @@ describe('GET /api/history/recent', () => {
});
});
describe('deduplication', () => {
it('suppresses a failed record when the same episode was subsequently imported', async () => {
const app = createApp({ skipRateLimits: true });
// API returns newest-first: imported (now) before failed (1hr ago)
setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01'));
expect(ep55Items).toHaveLength(1);
expect(ep55Items[0].outcome).toBe('imported');
});
it('shows a failed record as-is when there is no successful import and hasFile is false', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_FAILED_EP56], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const item = res.body.history.find(h => h.title && h.title.includes('S02E02'));
expect(item).toBeDefined();
expect(item.outcome).toBe('failed');
expect(item.availableForUpgrade).toBeFalsy();
});
it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const item = res.body.history.find(h => h.title && h.title.includes('S02E03'));
expect(item).toBeDefined();
expect(item.outcome).toBe('failed');
expect(item.availableForUpgrade).toBe(true);
});
it('does not expose _contentId in the response', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_IMPORTED_EP55], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
for (const item of res.body.history) {
expect(item).not.toHaveProperty('_contentId');
}
});
});
describe('response shape', () => {
it('returns correct top-level fields', async () => {
const app = createApp({ skipRateLimits: true });
@@ -286,4 +398,187 @@ describe('GET /api/history/recent', () => {
expect(Array.isArray(res.body.history)).toBe(true);
});
});
describe('staged loading - race conditions', () => {
it('handles concurrent requests without data loss', async () => {
const app = createApp({ skipRateLimits: true });
// Set up 150 records with unique episodeIds to test staged loading
const sonarrRecords = Array.from({ length: 150 }, (_, i) => ({
id: i + 1,
eventType: 'downloadFolderImported',
sourceTitle: `Show.S01E${i + 1}`,
date: new Date(Date.now() - i * 60000).toISOString(),
episodeId: i + 1, // Unique episodeId for each record
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
}));
setHistory(sonarrRecords, []);
const { cookies } = await loginAs(app);
// Make concurrent requests
const [res1, res2, res3] = await Promise.all([
request(app).get('/api/history/recent').set('Cookie', cookies),
request(app).get('/api/history/recent').set('Cookie', cookies),
request(app).get('/api/history/recent').set('Cookie', cookies)
]);
// All requests should succeed
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
expect(res3.status).toBe(200);
// All should return the same data (cache hit)
expect(res1.body.history).toEqual(res2.body.history);
expect(res2.body.history).toEqual(res3.body.history);
// Verify no duplicate episodeIds
const episodeIds = res1.body.history.map(h => h.title);
const uniqueEpisodeIds = new Set(episodeIds);
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
});
it('maintains cache consistency during background fetch', async () => {
const app = createApp({ skipRateLimits: true });
// Start with 100 records with unique episodeIds
const initialRecords = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
eventType: 'downloadFolderImported',
sourceTitle: `Show.S01E${i + 1}`,
date: new Date(Date.now() - i * 60000).toISOString(),
episodeId: i + 1,
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
}));
setHistory(initialRecords, []);
const { cookies } = await loginAs(app);
// First request populates cache
const res1 = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res1.status).toBe(200);
expect(res1.body.history).toHaveLength(100);
// Add more records to simulate background fetch
const additionalRecords = Array.from({ length: 50 }, (_, i) => ({
id: i + 101,
eventType: 'downloadFolderImported',
sourceTitle: `Show.S01E${i + 101}`,
date: new Date(Date.now() - (i + 100) * 60000).toISOString(),
episodeId: i + 101,
episode: { seasonNumber: 1, episodeNumber: i + 101, title: `Episode ${i + 101}`, hasFile: true },
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
seriesId: 10
}));
setHistory([...initialRecords, ...additionalRecords], []);
// Invalidate cache to simulate background fetch completion
cache.invalidate('history:sonarr');
cache.set('history:sonarr', [...initialRecords, ...additionalRecords].map(r => ({
...r,
_instanceName: 'Main Sonarr',
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
})), CACHE_TTL);
// Second request should get updated data
const res2 = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res2.status).toBe(200);
expect(res2.body.history).toHaveLength(150);
// Verify no duplicates
const episodeIds = res2.body.history.map(h => h.title);
const uniqueEpisodeIds = new Set(episodeIds);
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
});
it('handles duplicate records gracefully', async () => {
const app = createApp({ skipRateLimits: true });
// Create records with duplicate IDs (simulating race condition)
const records = [
{
id: 1,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01',
date: new Date().toISOString(),
series: { id: 10, title: 'My Show', tags: [1], images: [] },
seriesId: 10
},
{
id: 2,
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E02',
date: new Date(Date.now() - 60000).toISOString(),
series: { id: 10, title: 'My Show', tags: [1], images: [] },
seriesId: 10
},
{
id: 1, // Duplicate ID
eventType: 'downloadFolderImported',
sourceTitle: 'Show.S01E01',
date: new Date(Date.now() - 120000).toISOString(),
series: { id: 10, title: 'My Show', tags: [1], images: [] },
seriesId: 10
}
];
setHistory(records, []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
// The deduplication in history.js should handle this
// We should get 2 unique items, not 3
const uniqueSeries = new Set(res.body.history.map(h => h.title));
expect(uniqueSeries.size).toBeLessThanOrEqual(2);
});
});
describe('staged loading - edge cases', () => {
it('handles empty history', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toEqual([]);
});
it('handles single record', async () => {
const app = createApp({ skipRateLimits: true });
setHistory([SONARR_RECORD_IMPORTED], []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(1);
});
it('handles exactly 100 records (batch boundary)', async () => {
const app = createApp({ skipRateLimits: true });
const records = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
eventType: 'downloadFolderImported',
sourceTitle: `Show.S01E${(i % 10) + 1}`,
date: new Date(Date.now() - i * 60000).toISOString(),
series: { id: 10, title: 'My Show', tags: [1], images: [] },
seriesId: 10
}));
setHistory(records, []);
const { cookies } = await loginAs(app);
const res = await request(app)
.get('/api/history/recent')
.set('Cookie', cookies);
expect(res.status).toBe(200);
expect(res.body.history).toHaveLength(100);
});
});
});
+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 os from 'os';
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');
});
});
});

Some files were not shown because too many files have changed in this diff Show More