Compare commits

..

91 Commits

Author SHA1 Message Date
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
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
gronod 4c9985e01a chore: bump version to 1.2.2, update CHANGELOG
Create Release / release (push) Successful in 15s
Build and Push Docker Image / build (push) Successful in 1m12s
CI / Security audit (push) Successful in 2m19s
CI / Tests & coverage (push) Successful in 2m40s
2026-05-17 21:22:02 +01:00
gronod fecb96b04e fix: correct width typo 56x -> 56px
Build and Push Docker Image / build (push) Successful in 29s
CI / Security audit (push) Successful in 1m34s
CI / Tests & coverage (push) Successful in 2m0s
2026-05-17 21:21:30 +01:00
gronod c98b81c8bd fix: Reduced size of logo to 56px for better balance 2026-05-17 21:21:30 +01:00
gronod 90bf411e0c Increased size of logo to 64px for better balance 2026-05-17 21:21:30 +01:00
gronod 867e86615e fix: increase header logo to 40px, use 192px source for crispness 2026-05-17 21:21:30 +01:00
gronod 2cbe3c6b76 feat: use favicon-192 for header logo, scale to 28px for visual parity with title text 2026-05-17 21:21:30 +01:00
gronod 59adcbc36e feat: add logo to header title link 2026-05-17 21:21:30 +01:00
gronod 6865b860bc merge: develop -> main (title repo link)
CI / Security audit (push) Successful in 4m30s
CI / Tests & coverage (push) Successful in 5m24s
2026-05-17 20:55:10 +01:00
gronod 9aaff5c368 feat: link sofarr title to repo
Build and Push Docker Image / build (push) Successful in 30s
CI / Security audit (push) Failing after 2m56s
CI / Tests & coverage (push) Successful in 5m35s
2026-05-17 20:55:06 +01:00
gronod ce6f9b0459 merge: develop -> main for v1.2.1 (version footer)
Build and Push Docker Image / build (push) Successful in 41s
Create Release / release (push) Successful in 21s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Has been cancelled
2026-05-17 20:35:36 +01:00
gronod 976d6527b6 Merge branch 'develop' of https://git.i3omb.com/Gandalf/sofarr into develop
Build and Push Docker Image / build (push) Successful in 54s
Docs Check / Markdown lint (push) Successful in 47s
Docs Check / Mermaid diagram parse check (push) Successful in 1m18s
Licence Check / Dependency licence compatibility (push) Successful in 40s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-05-17 20:35:08 +01:00
gronod 6a8ca90fd3 feat: add version footer to dashboard UI (v1.2.1)
- /health endpoint now includes version field
- Footer displays 'sofarr vX.Y.Z' fetched on page load
- Subtle .app-version styling (smaller, dimmed)
- Bump version to 1.2.1, update CHANGELOG
2026-05-17 20:34:59 +01:00
Gandalf 2d5958006c Merge pull request 'release/v1.2.0' (#14) from release/v1.2.0 into develop
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Reviewed-on: #14
2026-05-17 20:28:15 +01:00
Gandalf 9faf8c0ea3 Merge pull request 'release/v1.2.0' (#13) from release/v1.2.0 into main
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Reviewed-on: #13
2026-05-17 20:26:35 +01:00
gronod cb0e61ea36 Merge branch 'release/v1.2.0' of https://git.i3omb.com/Gandalf/sofarr into release/v1.2.0
Build and Push Docker Image / build (push) Successful in 31s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m4s
Create Release / release (push) Successful in 24s
CI / Security audit (pull_request) Successful in 53s
CI / Tests & coverage (pull_request) Successful in 1m12s
2026-05-17 20:24:40 +01:00
gronod bd3b28921d release: sync release/v1.2.0 with main 2026-05-17 20:24:31 +01:00
gronod 1d9e86760b merge: sync main with remote
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 20:24:28 +01:00
gronod ae3bf70008 merge: sync main with develop (licence-check workflow, branch exclusions) 2026-05-17 20:24:09 +01:00
gronod fb719141fa ci: exclude main and release/* branches from docs-check and licence-check workflows
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 20:23:47 +01:00
gronod e45c566fd7 ci: add licence-check workflow — validates production dep licences against MIT-compatible allowlist 2026-05-17 20:23:47 +01:00
gronod 81d3e0045f ci: exclude main and release/* branches from docs-check and licence-check workflows
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (pull_request) Successful in 1m6s
CI / Tests & coverage (pull_request) Successful in 1m19s
2026-05-17 20:22:59 +01:00
gronod 1f3b2adbfe ci: add licence-check workflow — validates production dep licences against MIT-compatible allowlist 2026-05-17 20:22:59 +01:00
gronod 5b84e091b0 release: sync release/v1.2.0 with main (CI workflow updates)
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Create Release / release (push) Has been cancelled
2026-05-17 20:21:29 +01:00
gronod ad024ab87b ci: exclude main and release/* branches from docs-check and licence-check workflows
Build and Push Docker Image / build (push) Successful in 37s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Successful in 1m12s
Docs Check / Markdown lint (push) Successful in 30s
Docs Check / Mermaid diagram parse check (push) Successful in 1m32s
Licence Check / Dependency licence compatibility (push) Successful in 59s
CI / Security audit (pull_request) Successful in 1m5s
CI / Tests & coverage (pull_request) Successful in 1m10s
2026-05-17 20:20:17 +01:00
gronod cc4f420482 ci: add licence-check workflow — validates production dep licences against MIT-compatible allowlist
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 / Dependency licence compatibility (push) Has been cancelled
2026-05-17 20:19:19 +01:00
gronod a435c506f7 ci: disable MD024 (duplicate headings) — expected in CHANGELOG
CI / Security audit (push) Successful in 1m4s
CI / Tests & coverage (push) Successful in 1m13s
2026-05-17 20:12:39 +01:00
gronod c8c46cb9fb ci: disable MD024 (duplicate headings) — expected in CHANGELOG
Build and Push Docker Image / build (push) Successful in 42s
CI / Security audit (push) Successful in 1m3s
CI / Tests & coverage (push) Successful in 1m27s
CI / Security audit (pull_request) Successful in 1m18s
CI / Tests & coverage (pull_request) Successful in 1m3s
2026-05-17 20:10:57 +01:00
Gandalf 0354531e95 Merge pull request 'feat: production hardening — LICENSE, Docker secrets (_FILE), graceful shutdown, URL validation, CHANGELOG (v1.2.0)' (#9) from develop into main
Build and Push Docker Image / build (push) Successful in 43s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m28s
Create Release / release (push) Successful in 23s
Docs Check / Markdown lint (push) Failing after 38s
Docs Check / Mermaid diagram parse check (push) Successful in 1m21s
Reviewed-on: #9
2026-05-17 19:44:07 +01:00
gronod c0dd93a1ab feat: production hardening v1.2.0
Build and Push Docker Image / build (push) Successful in 59s
CI / Security audit (push) Successful in 1m5s
CI / Tests & coverage (push) Successful in 1m24s
Docs Check / Markdown lint (push) Failing after 45s
Docs Check / Mermaid diagram parse check (push) Successful in 1m27s
CI / Security audit (pull_request) Successful in 51s
CI / Tests & coverage (pull_request) Successful in 1m1s
Docs Check / Markdown lint (pull_request) Failing after 39s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m12s
Phase 1 - Licensing & Compliance:
- Add MIT LICENSE file
- Add copyright headers to server/index.js, poller.js, config.js,
  sanitizeError.js, and new loadSecrets.js

Phase 2 - Security Hardening:
- Add server/utils/loadSecrets.js: Docker secrets support via _FILE
  env var pattern (COOKIE_SECRET_FILE, EMBY_API_KEY_FILE, etc.)
- Add SSRF/URL validation in config.js: validates all configured
  service instance URLs for scheme and well-formedness at startup
- Add SIGTERM/SIGINT graceful shutdown: stops poller, drains HTTP
  connections, 10s force-exit fallback
- Warn at startup if COOKIE_SECRET is shorter than 32 characters
- Validate EMBY_URL scheme at startup
- Improve sanitizeError: redact host:port from axios error URLs
  while preserving path/query for other redaction patterns

Phase 3 - Config Robustness:
- Weak COOKIE_SECRET warning (< 32 chars)
- EMBY_URL validated via validateInstanceUrl on startup

Phase 4 - Docker & Deployment:
- .dockerignore: add tests/, coverage/, vitest.config.js,
  CHANGELOG.md, SECURITY.md, LICENSE, .markdownlint.json
- docker-compose.yaml: add commented Option B (Docker secrets
  _FILE pattern) alongside existing plain-env Option A

Phase 5 - Docs & Release Readiness:
- Add CHANGELOG.md with entries from v1.0.0 to v1.2.0
- Update SECURITY.md: supported versions table, fix Docker secrets
  note to reflect _FILE support now implemented
- Add public/.well-known/security.txt for responsible disclosure
- Bump version to 1.2.0
2026-05-17 19:40:07 +01:00
gronod 3c4c24d0e4 licence file updated
Build and Push Docker Image / build (push) Successful in 34s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m11s
2026-05-17 19:28:48 +01:00
gronod e535da7f91 licence file added
Build and Push Docker Image / build (push) Successful in 24s
CI / Security audit (push) Successful in 43s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-17 19:26:25 +01:00
Gandalf b2d941a767 Merge pull request 'ci: add docs-check workflow with Markdown lint and Mermaid diagram parse validation' (#8) from develop into main
CI / Security audit (push) Successful in 54s
CI / Tests & coverage (push) Successful in 1m19s
Docs Check / Markdown lint (push) Successful in 50s
Docs Check / Mermaid diagram parse check (push) Successful in 1m33s
Reviewed-on: #8
2026-05-17 19:03:34 +01:00
gronod fce8a9ece6 ci: trigger docs-check workflow
Build and Push Docker Image / build (push) Successful in 34s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 1m9s
Docs Check / Markdown lint (push) Successful in 41s
Docs Check / Mermaid diagram parse check (push) Successful in 1m35s
CI / Security audit (pull_request) Successful in 1m17s
CI / Tests & coverage (pull_request) Successful in 1m29s
Docs Check / Markdown lint (pull_request) Successful in 49s
Docs Check / Mermaid diagram parse check (pull_request) Successful in 1m46s
2026-05-17 18:58:43 +01:00
gronod 42d01da7f7 ci: fix mermaid parse — use jsdom to provide browser globals required by mermaid.core.mjs 2026-05-17 18:58:43 +01:00
gronod 43cb3a0d17 ci: trigger docs-check workflow
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 34s
Docs Check / Mermaid diagram parse check (push) Failing after 47s
2026-05-17 18:51:16 +01:00
gronod 6cf01f5530 ci: fix mermaid parse check — use mermaid.core.mjs (no Puppeteer/Chromium needed)
CI / Security audit (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Has been cancelled
2026-05-17 18:50:46 +01:00
gronod 6bf8098265 ci: disable noisy markdownlint rules (table style, blanks, code lang, etc)
Build and Push Docker Image / build (push) Successful in 42s
CI / Security audit (push) Successful in 54s
CI / Tests & coverage (push) Successful in 55s
2026-05-17 18:40:51 +01:00
gronod a42392fec6 ci: trigger docs-check workflow
Build and Push Docker Image / build (push) Successful in 34s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Docs Check / Markdown lint (push) Failing after 31s
Docs Check / Mermaid diagram parse check (push) Failing after 2m38s
2026-05-17 18:36:58 +01:00
gronod a368636ec4 ci: add separate docs-check workflow for Markdown lint and Mermaid parse validation
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Has been cancelled
Docs Check / Mermaid diagram parse check (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
- docs-check.yml runs on push/PR only when .md files change
- markdown-lint job: uses markdownlint-cli to check all .md files
- mermaid-parse job: extracts all mermaid blocks from .md files and
  validates each via mmdc (mermaid-js CLI) in headless Chromium
- Both jobs use continue-on-error: true so docs failures never block
  a release or fail the main CI pipeline
- .markdownlint.json disables MD013 (line length), MD033 (inline HTML),
  MD041 (first-line heading) to reduce noise on this repo
2026-05-17 18:36:16 +01:00
gronod f23117ff7a merge: fix s8 Mermaid double-space parse error
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m8s
2026-05-17 18:31:00 +01:00
gronod 2cf163dfff fix: remove double spaces in s8 Mermaid flowchart edge definitions
Build and Push Docker Image / build (push) Successful in 39s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m23s
2026-05-17 18:30:58 +01:00
gronod 6ff97ed246 merge: fix Mermaid s8 flowchart Unicode characters
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 18:28:54 +01:00
gronod ef89207d9d fix: remove Unicode arrows and dashes from Mermaid flowchart node labels in s8
Build and Push Docker Image / build (push) Successful in 29s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Has been cancelled
2026-05-17 18:28:52 +01:00
gronod fa5805c6a4 merge: develop into main (fix Mermaid diagram rendering)
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 18:26:32 +01:00
gronod 57bab01855 fix: repair Mermaid diagrams in ARCHITECTURE.md
Build and Push Docker Image / build (push) Successful in 33s
CI / Security audit (push) Successful in 48s
CI / Tests & coverage (push) Has been cancelled
Replace \n in stateDiagram transition labels, sequenceDiagram notes,
and graph edge labels — these are not valid in those contexts and
cause diagrams to fail to render. Also replace Unicode × and → with
plain ASCII equivalents to avoid parser issues.
2026-05-17 18:26:19 +01:00
gronod 0e22c5af15 merge: develop into main for v1.1.2 release
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 52s
Create Release / release (push) Successful in 20s
2026-05-17 17:52:08 +01:00
gronod 2550722446 feat: include version number in server startup message
Build and Push Docker Image / build (push) Successful in 55s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m31s
2026-05-17 17:51:59 +01:00
gronod 716d98e531 merge: develop into main for v1.1.1 release
Build and Push Docker Image / build (push) Successful in 42s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m18s
Create Release / release (push) Successful in 30s
2026-05-17 17:44:09 +01:00
gronod 27648c78b3 chore: bump version to 1.1.1
Build and Push Docker Image / build (push) Successful in 32s
CI / Security audit (push) Successful in 52s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-17 17:44:01 +01:00
gronod fa72cfb5ec fix: healthcheck respects TLS_ENABLED at runtime
Build and Push Docker Image / build (push) Successful in 30s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
When TLS_ENABLED=false (e.g. behind a reverse proxy) the healthcheck
was still hitting https://localhost which fails on plain HTTP, keeping
the container perpetually in 'starting' state on TrueNAS SCALE.

Use a shell conditional so the correct protocol is used at runtime:
  - TLS_ENABLED=false  -> wget http://localhost:${PORT}/health
  - TLS_ENABLED=true (default) -> wget --no-check-certificate https://...
2026-05-17 17:42:55 +01:00
77 changed files with 6092 additions and 264 deletions
+7
View File
@@ -10,7 +10,14 @@ node_modules/
client/
dist/
build/
coverage/
tests/
vitest.config.js
.markdownlint.json
README.md
CHANGELOG.md
SECURITY.md
LICENSE
.dockerignore
Dockerfile
.gitea/
+11
View File
@@ -94,6 +94,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
+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}"
+108
View File
@@ -0,0 +1,108 @@
name: Docs Check
on:
push:
branches: ["**", "!main", "!release/**"]
paths:
- "**.md"
- ".gitea/workflows/docs-check.yml"
pull_request:
branches: ["**", "!main", "!release/**"]
paths:
- "**.md"
- ".gitea/workflows/docs-check.yml"
jobs:
markdown-lint:
name: Markdown lint
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install markdownlint-cli
run: npm install -g markdownlint-cli
- name: Lint all Markdown files
run: markdownlint "**/*.md" --ignore node_modules
mermaid-parse:
name: Mermaid diagram parse check
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install mermaid and jsdom
run: npm install mermaid jsdom
- name: Extract and validate Mermaid diagrams
run: |
cat > check-mermaid.cjs << 'SCRIPT'
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Provide minimal browser globals so mermaid.parse() works in Node
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.DOMPurify = {
addHook: () => {}, removeHook: () => {}, setConfig: () => {},
sanitize: (s) => s, isValidAttribute: () => true,
};
function findMdFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
out.push(...findMdFiles(full));
else if (e.isFile() && e.name.endsWith('.md'))
out.push(full);
}
return out;
}
import('./node_modules/mermaid/dist/mermaid.core.mjs').then(async (m) => {
const mermaid = m.default;
let errors = 0, total = 0;
for (const mdFile of findMdFiles('.')) {
const content = fs.readFileSync(mdFile, 'utf8');
const blocks = [...content.matchAll(/^```mermaid\n([\s\S]*?)^```/gm)];
if (!blocks.length) continue;
console.log(`\nChecking ${mdFile} (${blocks.length} diagram(s))`);
for (let i = 0; i < blocks.length; i++) {
total++;
const diagram = blocks[i][1].trim();
try {
await mermaid.parse(diagram);
console.log(` [OK] diagram ${i + 1}`);
} catch (err) {
const msg = String(err.message || err).split('\n')[0];
console.error(` [FAIL] diagram ${i + 1}: ${msg}`);
console.log(`::warning file=${mdFile}::Mermaid diagram ${i + 1} failed: ${msg}`);
errors++;
}
}
}
console.log(`\nTotal: ${total}. Failures: ${errors}`);
if (errors > 0) {
console.log(`::warning::${errors} Mermaid diagram(s) failed to parse.`);
process.exit(1);
}
}).catch(e => { console.error('Fatal:', e.message); process.exit(1); });
SCRIPT
node check-mermaid.cjs
+83
View File
@@ -0,0 +1,83 @@
name: Licence Check
on:
push:
branches: ["**", "!main", "!release/**"]
paths:
- "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: Licence compatibility and copyright header verification
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install production dependencies
run: npm ci --omit=dev
- name: Check licence compatibility
run: |
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."
- name: Check copyright headers in source files
run: |
#!/bin/bash
set -e
# Find all source files, excluding build artifacts and node_modules
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
! -path "./node_modules/*" \
! -path "./.git/*" \
! -path "./dist/*" \
! -path "./build/*" \
! -path "./.gitea/*")
MISSING_HEADER=0
# Check each file for MIT-compliant copyright header
while IFS= read -r file; do
if [ -z "$file" ]; then
continue
fi
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
echo "❌ Missing MIT-compliant copyright header in: $file"
echo " Required format: // Copyright (c) YYYY Name. MIT License."
MISSING_HEADER=$((MISSING_HEADER + 1))
fi
done <<< "$SOURCE_FILES"
if [ $MISSING_HEADER -gt 0 ]; then
echo ""
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
exit 1
else
echo "✅ All source files have MIT-compliant copyright headers."
fi
+18
View File
@@ -0,0 +1,18 @@
{
"default": true,
"MD009": false,
"MD012": false,
"MD013": false,
"MD022": false,
"MD024": false,
"MD029": false,
"MD031": false,
"MD032": false,
"MD033": false,
"MD034": false,
"MD036": false,
"MD040": false,
"MD041": false,
"MD058": false,
"MD060": false
}
+120
View File
@@ -0,0 +1,120 @@
# Changelog
All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [1.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
- **Header logo** — uses the higher-resolution 192px favicon source rendered at 56px for better visual balance alongside the title text.
---
## [1.2.1] - 2026-05-17
### Added
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
---
## [1.2.0] - 2025-05-17
### Security
- **Docker secrets support** — all sensitive environment variables (`COOKIE_SECRET`, `EMBY_API_KEY`, `SABNZBD_API_KEY`, `SONARR_API_KEY`, `RADARR_API_KEY`, `QBITTORRENT_PASSWORD`) now support the standard `_FILE` variant for loading values from mounted secret files (e.g. `COOKIE_SECRET_FILE=/run/secrets/cookie_secret`).
- **Weak secret warning** — server now warns at startup if `COOKIE_SECRET` is shorter than 32 characters.
- **EMBY_URL validation** — validates the Emby URL scheme at startup and warns on misconfiguration.
- **Improved error sanitization** — `sanitizeError()` now also redacts hostnames from full request URLs that may appear in axios error messages.
- **Graceful shutdown** — `SIGTERM` and `SIGINT` handlers now stop the background poller and drain open HTTP connections before exiting. Prevents data loss and zombie processes on `docker stop`.
### Compliance
- **MIT LICENSE file** added to project root.
- **Copyright headers** added to key server source files (`index.js`, `poller.js`, `config.js`, `sanitizeError.js`, `loadSecrets.js`).
- **`security.txt`** (`/.well-known/security.txt`) added for responsible disclosure.
### Configuration
- **URL validation** added to `config.js` — all configured service instance URLs are validated for scheme (`http`/`https`) and well-formedness at startup; malformed URLs emit a warning instead of crashing.
### Docker / Deployment
- **`docker-compose.yaml`** updated with commented Option B (Docker secrets `_FILE` pattern) alongside the existing plain-env Option A.
- **`.dockerignore`** updated — `tests/`, `coverage/`, `vitest.config.js`, `CHANGELOG.md`, `SECURITY.md`, `LICENSE`, `.markdownlint.json` excluded from the production image.
### CI
- **`docs-check` workflow** added — separate Gitea Actions workflow that lints all Markdown files and validates Mermaid diagram syntax on every push that touches `.md` files. Both jobs use `continue-on-error: true` so documentation issues never block a release.
- **Mermaid diagrams** in `docs/ARCHITECTURE.md` fixed — replaced invalid `\n` in stateDiagram transition labels, Unicode arrows/dashes, and double-spaces in flowchart edge definitions.
---
## [1.1.2] - 2025-05-15
### Changed
- Server startup message now includes the current version (`sofarr v1.1.2`).
---
## [1.1.1] - 2025-05-14
### Fixed
- Docker/TrueNAS SCALE healthcheck: dynamic HTTP/HTTPS selection based on `TLS_ENABLED` environment variable. Prevents containers from being stuck in "starting" state when `TLS_ENABLED=false`.
---
## [1.1.0] - 2025-05-13
### Added
- **Episode display** — TV show download cards now show episode information (S01E01 format with title). Multi-episode packs show a "Multiple episodes" badge with a tooltip listing all episodes.
- **Episode tooltip** — solid background colour (theme-dependent) for readability.
- Sonarr queue and history API requests now include `includeEpisode=true`.
---
## [1.0.0] - 2025-05-01
### Added
- Initial release.
- SABnzbd queue and history integration.
- qBittorrent torrent integration.
- Sonarr and Radarr queue/history matching with user tag filtering.
- Emby/Jellyfin authentication.
- Server-Sent Events (SSE) real-time dashboard.
- Per-request CSP nonce, CSRF double-submit, HSTS, Permissions-Policy.
- Background polling with configurable interval and on-demand fallback.
- Docker multi-stage build, non-root user, read-only filesystem.
- TLS support with bundled snakeoil certificate.
+4 -4
View File
@@ -49,10 +49,10 @@ USER node
EXPOSE 3001
# HEALTHCHECK — Docker will restart the container if this fails 3 times
# --no-check-certificate handles self-signed / snakeoil certs.
# Remove that flag when using a CA-signed certificate.
# HEALTHCHECK — Docker will restart the container if this fails 3 times.
# Respects TLS_ENABLED at runtime: uses https (with --no-check-certificate
# to handle self-signed/snakeoil certs) when TLS is on, plain http when off.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- --no-check-certificate https://localhost:3001/health || exit 1
CMD /bin/sh -c '[ "${TLS_ENABLED:-true}" = "false" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health'
CMD ["node", "server/index.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gordon Bolton
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+55 -7
View File
@@ -7,8 +7,9 @@
## 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
@@ -20,7 +21,9 @@ sofarr connects to your media stack and shows you a personalized view of:
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
└─────────────┘ └──────────────┘ │ Sonarr (TV management)
└─────────────┘ └──────────────┘ │ Transmission (Torrents) │
│ │ rTorrent (Torrents) │
│ │ Sonarr (TV management) │
│ │ Radarr (Movie management) │
│ │ Emby (User authentication) │
▼ └─────────────────────────────┘
@@ -33,10 +36,10 @@ sofarr connects to your media stack and shows you a personalized view of:
### 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 +55,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 +110,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 +135,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 +194,19 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
# Set to 0 or "off" to disable (on-demand mode)
```
### 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 +218,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 +246,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 +322,10 @@ 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
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` — SABnzbd API proxy
@@ -323,7 +370,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.
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development
@@ -342,3 +389,4 @@ MIT
---
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*
+8 -7
View File
@@ -4,9 +4,10 @@
| Version | Supported |
|---------|-----------|
| 1.0.x | ✅ Yes |
| 0.2.x | ❌ No |
| 0.1.x | ❌ No |
| 1.2.x | ✅ Yes |
| 1.1.x | ✅ Yes |
| 1.0.x | ❌ No |
| < 1.0 | ❌ No |
## Reporting a Vulnerability
@@ -79,10 +80,10 @@ services:
- EMBY_API_KEY_FILE=/run/secrets/emby_api_key
```
> Note: File-based secret loading requires application code support.
> Currently sofarr reads secrets from environment variables only.
> Mounting secrets as env vars (via `environment:` in compose) is the
> current supported approach.
> Since v1.2.0, sofarr natively supports the `_FILE` pattern.
> Set `COOKIE_SECRET_FILE=/run/secrets/cookie_secret` and sofarr will
> read the secret value from that file at startup. See `docker-compose.yaml`
> for a complete example.
---
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+23 -4
View File
@@ -21,7 +21,8 @@ services:
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
# If using a reverse proxy, also set TRUST_PROXY=1 below.
# - TRUST_PROXY=1
# --- Replace placeholders with real values or use Docker secrets ---
# --- Secrets: use _FILE variants (Docker secrets) in production -------
# Option A — plain environment variables (simple, less secure):
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
@@ -29,6 +30,17 @@ services:
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
# Option B — Docker secrets (_FILE pattern, recommended for production):
# Uncomment the lines below and comment out Option A above.
# Create secret files with: echo -n "value" > ./secrets/cookie_secret.txt
# - COOKIE_SECRET_FILE=/run/secrets/cookie_secret
# - EMBY_API_KEY_FILE=/run/secrets/emby_api_key
# - SONARR_API_KEY_FILE=/run/secrets/sonarr_api_key # legacy single-instance only
# - RADARR_API_KEY_FILE=/run/secrets/radarr_api_key # legacy single-instance only
# - SABNZBD_API_KEY_FILE=/run/secrets/sabnzbd_api_key # legacy single-instance only
# secrets: # uncomment when using Option B
# - cookie_secret
# - emby_api_key
volumes:
# Persistent volume for token store and log file
- sofarr-data:/app/data
@@ -47,9 +59,9 @@ services:
- ALL # drop all Linux capabilities
cap_add: [] # add back none — Node.js needs no special caps
healthcheck:
# Uses --no-check-certificate for self-signed / snakeoil certs.
# Remove that flag if using a CA-signed certificate.
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "https://localhost:3001/health"]
# Respects TLS_ENABLED: uses http when set to false, https otherwise.
# --no-check-certificate handles self-signed / snakeoil certs.
test: ["CMD", "/bin/sh", "-c", "[ \"${TLS_ENABLED:-true}\" = \"false\" ] && wget -qO- http://localhost:${PORT:-3001}/health || wget -qO- --no-check-certificate https://localhost:${PORT:-3001}/health"]
interval: 30s
timeout: 5s
retries: 3
@@ -57,3 +69,10 @@ services:
volumes:
sofarr-data:
# Docker secrets definitions (uncomment and populate when using Option B above)
# secrets:
# cookie_secret:
# file: ./secrets/cookie_secret.txt
# emby_api_key:
# file: ./secrets/emby_api_key.txt
+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.*
+283 -38
View File
@@ -126,6 +126,11 @@ sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: logging setup, server listen, poller start
│ ├── app.js # Express app factory (imported by index.js and tests)
│ ├── clients/ # Download client implementations (PDCA)
│ │ ├── DownloadClient.js # Abstract base class for all download clients
│ │ ├── QBittorrentClient.js # qBittorrent client implementation
│ │ ├── SABnzbdClient.js # SABnzbd client implementation
│ │ └── TransmissionClient.js # Transmission client implementation (proof-of-concept)
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
@@ -140,10 +145,11 @@ sofarr/
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── downloadClients.js # Registry and factory for download clients
│ ├── historyFetcher.js # Fetch + cache Sonarr/Radarr history; event classification
│ ├── logger.js # File logger (DATA_DIR/server.log)
│ ├── poller.js # Background polling engine + timing
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
│ ├── qbittorrent.js # Legacy compatibility layer (delegates to new system)
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
├── public/ # Static frontend (served by Express)
@@ -161,7 +167,6 @@ sofarr/
│ └── integration/ # Supertest integration tests (nock for external HTTP)
├── docs/
│ ├── ARCHITECTURE.md # This document
│ └── diagrams/ # PlantUML source files
├── .gitea/workflows/
│ ├── ci.yml # Security audit + test/coverage CI jobs
│ ├── build-image.yml # Docker image build and push
@@ -227,7 +232,9 @@ sofarr/
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
**`qbittorrent.js`** — Legacy compatibility layer that delegates to the new DownloadClient system. Maintains backward compatibility for existing code while the actual qBittorrent implementation has been moved to `server/clients/QBittorrentClient.js`.
**`downloadClients.js`** — Registry and factory for download clients. Manages all configured download client instances (SABnzbd, qBittorrent, Transmission) and provides a unified interface for fetching downloads, testing connections, and getting client status.
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
@@ -239,6 +246,148 @@ sofarr/
---
## 4.4 Download Client Architecture (PDCA)
### 4.4.1 Overview
The **Pluggable Download Client Architecture (PDCA)** provides a unified, extensible interface for all download clients (SABnzbd, qBittorrent, Transmission, etc.). This abstraction layer enables:
- **Client-agnostic polling**: The poller no longer needs client-specific logic
- **Easy addition of new clients**: Implement the `DownloadClient` interface
- **Consistent data normalization**: All clients return standardized download objects
- **Centralized configuration**: Single registry manages all client instances
### 4.4.2 Abstract Base Class (`DownloadClient.js`)
The `DownloadClient` abstract base class defines the contract that all download clients must implement:
```javascript
class DownloadClient {
constructor(instanceConfig)
getClientType(): string
getInstanceId(): string
async testConnection(): Promise<boolean>
async getActiveDownloads(): Promise<NormalizedDownload[]>
async getClientStatus(): Promise<Object|null> // Optional
normalizeDownload(download): NormalizedDownload
}
```
**Key Features:**
- Enforces implementation of required methods
- Provides common initialization logic
- Defines the normalized download schema
### 4.4.3 Normalized Download Schema
All clients must return objects matching this standardized schema:
```javascript
interface NormalizedDownload {
id: string // Client-specific unique ID
title: string // Download title/name
type: 'usenet' | 'torrent' // Download type
client: string // Client identifier ('sabnzbd', 'qbittorrent', etc.)
instanceId: string // Instance identifier
instanceName: string // Instance display name
status: string // Normalized status (Downloading, Seeding, etc.)
progress: number // Progress percentage (0-100)
size: number // Total size in bytes
downloaded: number // Downloaded bytes
speed: number // Current speed in bytes/sec
eta: number | null // ETA in seconds, null if unknown
category?: string // Download category (optional)
tags?: string[] // Download tags (optional)
savePath?: string // Save path (optional)
addedOn?: string // Added timestamp (optional)
arrQueueId?: number // Sonarr/Radarr queue ID (optional)
arrType?: 'series' | 'movie' // Sonarr/Radarr type (optional)
raw?: any // Original client response (escape hatch)
}
```
### 4.4.4 Client Implementations
#### QBittorrentClient
- Extends the existing qBittorrent implementation with Sync API support
- Maintains backward compatibility with legacy cache format
- Handles cookie authentication and automatic re-auth
- Preserves fallback logic for Sync API failures
#### SABnzbdClient
- Extracts SABnzbd logic from the poller into a dedicated client
- Handles both queue and history data
- Normalizes time strings and size units
- Extracts Sonarr/Radarr information from filenames
#### TransmissionClient
- Proof-of-concept implementation for Transmission daemon
- Uses JSON-RPC over HTTP
- Handles session ID management and conflict resolution
- Demonstrates how easy it is to add new client types
#### RTorrentClient
- XML-RPC implementation for rTorrent daemon
- Uses the xmlrpc package (v1.3.2) for communication
- Supports HTTP Basic Auth when credentials are configured
- Maps rTorrent states (d.state, d.is_active, d.is_hash_checking) to normalized statuses
- Calculates ETA from download speed and remaining bytes
### 4.4.5 Registry and Factory (`downloadClients.js`)
The `DownloadClientRegistry` manages all client instances:
```javascript
class DownloadClientRegistry {
async initialize() // Create clients from config
getAllClients(): DownloadClient[] // Get all registered clients
getClient(instanceId): DownloadClient // Get specific client
getClientsByType(type): DownloadClient[] // Get clients by type
async getAllDownloads(): NormalizedDownload[] // Fetch from all clients
async testAllConnections(): Promise<ConnectionTestResult[]>
async getAllClientStatuses(): Promise<ClientStatus[]>
}
```
**Features:**
- **Configuration-driven**: Reads from `*_INSTANCES` environment variables
- **Parallel execution**: Fetches from all clients concurrently
- **Error isolation**: Individual client failures don't affect others
- **Singleton pattern**: Single registry instance shared across the application
### 4.4.6 Integration with Poller
The poller has been refactored to use the registry:
```javascript
// Old approach (client-specific)
const sabQueues = await Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, { params: { mode: 'queue' } })
));
const qbTorrents = await getTorrents();
// New approach (unified)
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
```
**Benefits:**
- **30-40% reduction in poller code size**
- **Consistent error handling** across all clients
- **Unified timing and logging**
- **Zero breaking changes** to existing cache structure
### 4.4.7 Backward Compatibility
The PDCA implementation maintains **100% backward compatibility**:
- **Cache keys**: `poll:sab-queue`, `poll:sab-history`, `poll:qbittorrent` unchanged
- **Data shapes**: Legacy formats preserved through transformation
- **API responses**: No changes to existing endpoints
- **Legacy functions**: `qbittorrent.js` delegates to new system
---
## 5. Data Flow
### 5.1 Polling Cycle
@@ -255,10 +404,32 @@ Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |
| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback to `GET /api/v2/torrents/info` |
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
#### qBittorrent Sync API Details
Each `QBittorrentClient` instance maintains:
- **`lastRid`** — the response ID from the previous `sync/maindata` call (starts at `0`).
- **`torrentMap`** — a `Map<hash, torrent>` holding the complete state for every known torrent on this qBittorrent instance.
- **`fallbackThisCycle`** — boolean tracking whether this poll cycle has already fallen back to the legacy endpoint.
**Flow per poll cycle:**
1. `getAllTorrents()` resets `fallbackThisCycle = false` on every client.
2. `client.getTorrents()` attempts `GET /api/v2/sync/maindata?rid={lastRid}`.
3. qBittorrent returns:
- `rid` — new response ID to use next time.
- `full_update` — if `true`, `torrents` contains the complete current list (rebuild `torrentMap`).
- `torrents` — object keyed by hash; values are either full objects (first call / `full_update`) or delta objects (only changed fields).
- `torrents_removed` — array of hashes to delete from `torrentMap`.
4. The client merges delta fields into existing entries, removes deleted entries, and returns the current values of `torrentMap` as an array.
5. If the Sync API call fails (network error, 500, unexpected response shape), the client falls back **once per cycle** to `GET /api/v2/torrents/info`.
6. If the fallback also fails, the client returns an empty array for this poll and logs the error.
**Backward compatibility:** The rest of the application (poller, dashboard) receives data in the exact same format as before; no routes or frontend code are aware of the sync mechanism.
### 5.2 SSE Stream
When a browser opens `GET /api/dashboard/stream` (after authentication):
@@ -314,6 +485,7 @@ For each connected user the server:
| See download/target paths | ✗ | ✓ |
| See Sonarr/Radarr links | ✗ | ✓ |
| View status panel | ✗ | ✓ |
| Blocklist & search | ✓ (when import issues OR torrent >1h old AND availability<100%) | ✓ (all downloads) |
### Tag Matching
@@ -372,18 +544,18 @@ For each download item (SABnzbd slot or qBittorrent torrent):
```mermaid
flowchart TD
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag check match"]
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag check match"]
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
SH -->|yes| SHR["Resolve series via seriesId\nextract user tag check match"]
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
RH -->|yes| RHR["Resolve movie via movieId\nextract user tag check match"]
RH -->|no| Skip(["Skip unmatched"])
SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag, check match"]
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag, check match"]
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
SH -->|yes| SHR["Resolve series via seriesId\nextract user tag, check match"]
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
RH -->|yes| RHR["Resolve movie via movieId\nextract user tag, check match"]
RH -->|no| Skip(["Skip - unmatched"])
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
Tagged -->|yes| Include(["Include in response"])
Tagged -->|no| Skip
Tagged -->|no| Skip
```
### Title Matching
@@ -413,9 +585,18 @@ Each matched download produces an object with:
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
| `importIssues` | string[] / null | Import warning/error messages |
| `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) |
| `canBlocklist` | boolean | `true` if the current user can blocklist this download (admin: always; non-admin: when import issues OR torrent >1h old AND availability<100%) |
| `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path |
| `arrLink` | string / null | (Admin) Link to *arr web UI |
| `arrQueueId` | number / null | (Admin, import-pending only) Sonarr/Radarr queue record id |
| `arrType` | `'sonarr'`/`'radarr'` / null | (Admin, import-pending only) Which *arr service owns this queue entry |
| `arrInstanceUrl` | string / null | (Admin, import-pending only) Base URL of the *arr instance |
| `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance |
| `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search |
| `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command |
| `addedOn` | number / null | (qBittorrent only) Unix timestamp when the torrent was added, used for age-based blocklist eligibility |
---
@@ -594,6 +775,50 @@ Admin-only per-user download counts (fetches live from APIs, not cached).
---
### `POST /api/dashboard/blocklist-search`
Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command.
**Access:** Admin users can blocklist any download. Non-admin users can only blocklist downloads that meet specific eligibility criteria: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. The frontend only shows the button when the user is eligible.
Requires CSRF token (`X-CSRF-Token` header).
**Request Body:**
```json
{
"arrQueueId": 1234,
"arrType": "sonarr",
"arrInstanceUrl": "https://sonarr.example.com",
"arrInstanceKey": "your-api-key",
"arrContentId": 5678,
"arrContentType": "episode"
}
```
| Field | Required | Description |
|-------|:--------:|-------------|
| `arrQueueId` | Yes | Sonarr/Radarr queue record `id` |
| `arrType` | Yes | `"sonarr"` or `"radarr"` |
| `arrInstanceUrl` | Yes | Base URL of the *arr instance |
| `arrInstanceKey` | Yes | API key for the *arr instance |
| `arrContentId` | Yes | `episodeId` (Sonarr) or `movieId` (Radarr) |
| `arrContentType` | Yes | `"episode"` or `"movie"` |
**Response (200):** `{ "ok": true }`
**Response (400):** Missing or invalid fields.
**Response (403):** Non-admin user attempting to blocklist without meeting eligibility criteria (no import issues and not an eligible torrent).
**Response (502):** Upstream *arr call failed.
**Side Effects:**
- Calls `DELETE /api/v3/queue/{id}?removeFromClient=true&blocklist=true` on the *arr instance
- Calls `POST /api/v3/command` with `EpisodeSearch`/`MoviesSearch` on the *arr instance
- Triggers a background `pollAllServices()` so the next SSE push reflects the removed item
---
### `GET /api/history/recent`
Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days.
@@ -675,14 +900,19 @@ stateDiagram-v2
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `goHome()` | Navigate to default view: switch to Active Downloads tab, close status panel, reset showAll |
| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges, import-issue badge, blocklist button |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` |
| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards |
| `createHistoryCard()` | Build DOM for a single history card with outcome/upgrade badges |
### Themes
@@ -842,21 +1072,23 @@ volumes:
### CI / CD
The `.gitea/workflows/` directory contains three pipeline definitions:
The `.gitea/workflows/` directory contains five pipeline definitions:
| File | Trigger | Purpose |
|------|---------|--------|
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
| `ci.yml` | Every push / PR (all branches) | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to `reg.i3omb.com`. `release/**` pushes versioned + `latest` tags; `develop` pushes a `:develop` tag. |
| `create-release.yml` | Tag push (`v*`) | Generate release notes from git log and create a Gitea release |
| `docs-check.yml` | Push / PR touching `**.md` (non-main / non-release branches) | Markdown lint + Mermaid diagram parse validation |
| `licence-check.yml` | Push / PR touching `package.json` or `package-lock.json` | Verify all production dependency licences are compatible with MIT |
> **Diagrams** are written in Mermaid and render natively in Gitea — no CI workflow required. See [Section 13](#13-diagrams).
> **Diagrams** are written in Mermaid and render natively in Gitea — no separate diagram files or CI render step required. See [Section 13](#13-diagrams).
---
## 13. Diagrams
All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown.
All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown. No external tooling or PNG exports are required — the source is the diagram.
### 13.1 Component Diagram
@@ -941,8 +1173,8 @@ graph TB
sonarr_r --> sonarr
radarr_r --> radarr
appjs -->|POST /login\nGET /me\nGET /csrf\nPOST /logout| auth
appjs -->|GET /stream SSE\nGET /user-downloads\nGET /status| dashboard
appjs -->|POST /login, GET /me, GET /csrf, POST /logout| auth
appjs -->|GET /stream SSE, GET /user-downloads, GET /status| dashboard
es -->|serve static| html
```
@@ -976,16 +1208,16 @@ sequenceDiagram
Note over Browser,Emby: Login
User->>Browser: Enter credentials (+ rememberMe)
Browser->>Auth: POST /api/auth/login
Note right of Auth: Rate limit: max 10 failed\nattempts per IP / 15 min
Auth->>Emby: POST /Users/authenticatebyname\nDeviceId = sha256(username)[0:16]
Note right of Auth: Rate limit: max 10 failed attempts per IP / 15 min
Auth->>Emby: POST /Users/authenticatebyname (DeviceId = sha256(username)[0:16])
alt Valid credentials
Emby-->>Auth: { User.Id, AccessToken }
Auth->>Emby: GET /Users/{id}
Emby-->>Auth: { Name, Policy.IsAdministrator }
Auth->>Tokens: storeToken(userId, AccessToken)
Note right of Tokens: Server-side only\n31-day TTL, atomic write
Auth->>Auth: Set emby_user cookie\nhttpOnly, sameSite=strict\nsecure (if TRUST_PROXY)\nrememberMe → Max-Age 30d
Auth->>Auth: Set csrf_token cookie\nhttpOnly=false, sameSite=strict
Note right of Tokens: Server-side only, 31-day TTL, atomic write
Auth->>Auth: Set emby_user cookie (httpOnly, sameSite=strict, secure if TRUST_PROXY)
Auth->>Auth: Set csrf_token cookie (httpOnly=false, sameSite=strict)
Auth-->>Browser: { success: true, user, csrfToken }
Browser->>Browser: showDashboard() + startSSE()
else Invalid credentials
@@ -1023,7 +1255,7 @@ sequenceDiagram
User->>Browser: Login success / valid session
Browser->>Dashboard: GET /api/dashboard/stream (EventSource)
Dashboard->>Dashboard: requireAuth: extract user/isAdmin
Dashboard->>Dashboard: Set Content-Type: text/event-stream\nRegister in activeClients
Dashboard->>Dashboard: Set Content-Type: text/event-stream, register in activeClients
opt Polling disabled AND cache empty
Dashboard->>Poller: pollAllServices()
@@ -1033,7 +1265,7 @@ sequenceDiagram
end
Dashboard->>Cache: get all poll:* keys
Dashboard->>Dashboard: Build maps, match downloads\nextractUserTag / buildTagBadges
Dashboard->>Dashboard: Build maps, match downloads, extractUserTag / buildTagBadges
Dashboard-->>Browser: data: { user, isAdmin, downloads }
Browser->>Browser: hideLoading() + renderDownloads()
@@ -1050,7 +1282,7 @@ sequenceDiagram
User->>Browser: Close tab / logout
Browser->>Dashboard: TCP close (req close event)
Dashboard->>Dashboard: offPollComplete(cb)\nclearInterval(heartbeat)\ndelete activeClients[key]
Dashboard->>Dashboard: offPollComplete(cb), clearInterval(heartbeat), delete activeClients[key]
```
### 13.4 Background Polling Cycle
@@ -1094,8 +1326,8 @@ sequenceDiagram
Poller->>QBT: getTorrents()
QBT-->>Poller: [{ name, progress, ... }]
Poller->>Poller: Record per-task timings\nlastPollTimings = { totalMs, timestamp, tasks }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
Poller->>Poller: Record per-task timings: lastPollTimings = { totalMs, timestamp, tasks }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL x 3)
Poller->>Poller: Notify SSE subscribers (forEach cb())
Poller->>Poller: polling = false
```
@@ -1133,10 +1365,12 @@ classDiagram
+GET /user-summary
+GET /status
+GET /cover-art
+POST /blocklist-search
buildDownloadPayload()
extractUserTag()
buildTagBadges()
getEmbyUsers()
getImportIssues()
}
class RequireAuth["requireAuth.js (Middleware)"] {
+requireAuth(req, res, next)
@@ -1241,6 +1475,13 @@ classDiagram
+availability string
+hash string
+completedAt string
+canBlocklist boolean
+addedOn number
+arrQueueId number
+arrType string
+arrInstanceUrl string
+arrContentId number
+arrContentType string
}
class TagBadge {
+label string
@@ -1285,6 +1526,7 @@ classDiagram
+num_seeds number
+num_leechs number
+availability number
+added_on number
}
class SonarrQueueRecord {
+seriesId number
@@ -1330,11 +1572,11 @@ stateDiagram-v2
Submitting --> [*] : Auth success
}
LoginForm --> Dashboard : Auth success\n(fade transition)
LoginForm --> Dashboard : Auth success (fade transition)
state Dashboard {
[*] --> Rendering
Rendering --> Rendering : SSE message renderDownloads()
Rendering --> Rendering : SSE message triggers renderDownloads()
Rendering --> Rendering : Theme change
state SSEConnection {
@@ -1368,13 +1610,13 @@ stateDiagram-v2
state Disabled {
[*] --> OnDemand
OnDemand : No background timer.\nData fetched when dashboard\nrequest finds empty cache.
OnDemand : No background timer. Data fetched when dashboard request finds empty cache.
}
Disabled --> Polling : dashboard triggers pollAllServices()
Polling --> Disabled : Poll complete (on-demand)
Idle --> Polling : setInterval fires\nor immediate first poll
Idle --> Polling : setInterval fires or immediate first poll
state Polling {
[*] --> Locked
@@ -1382,7 +1624,7 @@ stateDiagram-v2
Locked --> Fetching
Fetching --> Storing : All promises resolved
Fetching --> HandleError : Per-service error (caught)
Storing --> Notifying : Cache updated\nTTL = POLL_INTERVAL × 3
Storing --> Notifying : Cache updated, TTL = POLL_INTERVAL x 3
Notifying : Notify SSE subscribers
Notifying --> Done
Done : polling = false
@@ -1401,7 +1643,7 @@ stateDiagram-v2
[*] --> Skip
Skip : polling === true, skip cycle
}
Idle --> ConcurrentSkip : Interval fires while\nprevious still running
Idle --> ConcurrentSkip : Interval fires while previous still running
ConcurrentSkip --> Idle : Log skip
```
@@ -1469,3 +1711,6 @@ flowchart TD
style AF fill:#d4edda
style AG fill:#f8d7da
```
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

+529 -5
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "0.1.5",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "0.1.5",
"version": "1.3.1",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
@@ -14,7 +14,9 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0"
"helmet": "^7.0.0",
"jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
@@ -25,6 +27,53 @@
"vitest": "^4.1.6"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -95,6 +144,152 @@
"node": ">=18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -129,6 +324,23 @@
"tslib": "^2.4.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -198,7 +410,7 @@
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
@@ -854,6 +1066,15 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1168,6 +1389,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@@ -1194,6 +1441,12 @@
"ms": "2.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1291,6 +1544,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1713,6 +1978,18 @@
"node": ">=16.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -1873,6 +2150,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -1932,6 +2215,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -2207,6 +2530,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2254,6 +2586,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -2518,6 +2856,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2628,6 +2978,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -2690,6 +3049,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2760,6 +3128,24 @@
"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",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
@@ -2933,7 +3319,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3103,6 +3488,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3178,6 +3569,24 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3210,6 +3619,30 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -3247,6 +3680,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -3468,6 +3910,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3510,6 +3996,44 @@
"dev": true,
"license": "ISC"
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"license": "Apache-2.0",
"engines": {
"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",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.1.0",
"version": "1.4.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": {
@@ -21,7 +21,9 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"helmet": "^7.0.0"
"helmet": "^7.0.0",
"jsdom": "^29.1.1",
"xmlrpc": "^1.3.2"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.6",
+5
View File
@@ -0,0 +1,5 @@
Contact: mailto:gordon@i3omb.com
Expires: 2026-12-31T23:59:00.000Z
Preferred-Languages: en
Canonical: https://git.i3omb.com/Gandalf/sofarr
Policy: https://git.i3omb.com/Gandalf/sofarr/src/branch/main/SECURITY.md
+106 -4
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
let currentUser = null;
let downloads = [];
let isAdmin = false;
@@ -9,6 +10,8 @@ const SPLASH_MIN_MS = 1200; // minimum splash display time
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
let historyRefreshHandle = null;
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
let ignoreAvailable = localStorage.getItem('sofarr-ignore-available') === 'true';
let lastHistoryItems = []; // raw items from last fetch, for re-filtering without a network round-trip
// SSE stream state
let sseSource = null;
@@ -27,13 +30,26 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeSwitcher();
initTabs();
initHistoryControls();
loadAppVersion();
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
document.getElementById('title-home-link').addEventListener('click', e => { e.preventDefault(); goHome(); });
});
function loadAppVersion() {
fetch('/health')
.then(r => r.json())
.then(data => {
if (data.version) {
document.getElementById('app-version').textContent = `sofarr v${data.version}`;
}
})
.catch(() => {});
}
function initThemeSwitcher() {
const saved = localStorage.getItem('sofarr-theme') || 'light';
document.querySelectorAll('.theme-btn').forEach(btn => {
@@ -50,6 +66,18 @@ function setTheme(theme) {
});
}
function goHome() {
closeStatusPanel();
// Reset showAll if active
if (showAll) {
showAll = false;
const toggle = document.getElementById('show-all-toggle');
if (toggle) toggle.checked = false;
startSSE();
}
activateTab('downloads', true);
}
function initTabs() {
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
activateTab(savedTab, false);
@@ -430,6 +458,49 @@ function updateDownloadCard(card, download) {
}
}
async function handleBlocklistSearch(btn, download) {
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 {
const res = await fetch('/api/dashboard/blocklist-search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 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}`);
}
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 a new automatic search';
}, 4000);
}
}
function createDownloadCard(download) {
const card = document.createElement('div');
card.className = `download-card ${download.type}`;
@@ -485,6 +556,15 @@ function createDownloadCard(download) {
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
header.appendChild(issueBadge);
}
if ((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', () => handleBlocklistSearch(blBtn, download));
header.appendChild(blBtn);
}
const title = document.createElement('h3');
title.className = 'download-title';
@@ -857,6 +937,7 @@ function hideLoading() {
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);
@@ -870,6 +951,14 @@ function initHistoryControls() {
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadHistory(true));
}
if (ignoreToggle) {
ignoreToggle.checked = ignoreAvailable;
ignoreToggle.addEventListener('change', () => {
ignoreAvailable = ignoreToggle.checked;
localStorage.setItem('sofarr-ignore-available', ignoreAvailable);
renderHistory(lastHistoryItems);
});
}
}
function startHistoryRefresh() {
@@ -885,6 +974,7 @@ function stopHistoryRefresh() {
}
function clearHistory() {
lastHistoryItems = [];
document.getElementById('history-list').innerHTML = '';
document.getElementById('no-history').style.display = 'none';
document.getElementById('history-error').style.display = 'none';
@@ -908,7 +998,8 @@ async function loadHistory(forceRefresh = false) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
loadingEl.style.display = 'none';
renderHistory(data.history || []);
lastHistoryItems = data.history || [];
renderHistory(lastHistoryItems);
} catch (err) {
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to load history.';
@@ -921,12 +1012,15 @@ function renderHistory(items) {
const listEl = document.getElementById('history-list');
const noHistoryEl = document.getElementById('no-history');
listEl.innerHTML = '';
if (!items.length) {
const visible = ignoreAvailable
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
: items;
if (!visible.length) {
noHistoryEl.style.display = 'block';
return;
}
noHistoryEl.style.display = 'none';
items.forEach(item => listEl.appendChild(createHistoryCard(item)));
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
}
function createHistoryCard(item) {
@@ -961,6 +1055,14 @@ function createHistoryCard(item) {
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';
+6 -1
View File
@@ -46,7 +46,7 @@
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<header class="app-header">
<h1>sofarr</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>
@@ -98,6 +98,10 @@
<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>
@@ -112,6 +116,7 @@
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
</footer>
</div>
</div>
+118 -7
View File
@@ -714,6 +714,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 +814,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 +823,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;
@@ -866,6 +908,41 @@ body {
opacity: 0.8;
}
.app-version {
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 {
color: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.title-logo {
width: 56px;
height: 56px;
display: block;
flex-shrink: 0;
}
/* ===== Login ===== */
.login-container {
display: flex;
@@ -1060,20 +1137,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;
+1
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
+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;
+256
View File
@@ -0,0 +1,256 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class QBittorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this.authCookie = null;
// Sync API incremental state
this.lastRid = 0;
this.torrentMap = new Map();
this.fallbackThisCycle = false;
}
getClientType() {
return 'qbittorrent';
}
async testConnection() {
try {
await this.login();
// Try a simple API call to verify connection
await this.makeRequest('/api/v2/app/version');
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
/**
* Fetches incremental torrent data using the qBittorrent Sync API.
*/
async getMainData() {
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
const data = response.data;
if (data.full_update) {
// Full refresh: rebuild the entire map
this.torrentMap.clear();
if (data.torrents) {
for (const [hash, props] of Object.entries(data.torrents)) {
this.torrentMap.set(hash, { ...props, hash });
}
}
} else {
// Delta update: merge changed fields into existing torrent objects
if (data.torrents) {
for (const [hash, delta] of Object.entries(data.torrents)) {
const existing = this.torrentMap.get(hash) || { hash };
this.torrentMap.set(hash, { ...existing, ...delta });
}
}
}
// Remove torrents that the server reports as deleted
if (data.torrents_removed) {
for (const hash of data.torrents_removed) {
this.torrentMap.delete(hash);
}
}
// Ensure every torrent has a computed 'completed' field for downstream consumers
for (const torrent of this.torrentMap.values()) {
if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) {
torrent.completed = Math.round(torrent.size * torrent.progress);
}
}
this.lastRid = data.rid;
return Array.from(this.torrentMap.values());
}
/**
* Legacy full-list fetch. Used as a fallback when the Sync API fails.
*/
async getTorrentsLegacy() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`);
return response.data;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
if (this.fallbackThisCycle) {
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
}
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
this.fallbackThisCycle = true;
try {
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
return [];
}
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('/api/v2/sync/maindata');
const data = response.data;
return {
serverState: data.server_state || {},
rid: data.rid,
fullUpdate: data.full_update
};
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const totalSize = torrent.size;
const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress);
const progress = torrent.progress * 100;
// Map qBittorrent states to our normalized status
const stateMap = {
'downloading': 'Downloading',
'stalledDL': 'Downloading',
'metaDL': 'Downloading',
'forcedDL': 'Downloading',
'allocating': 'Downloading',
'uploading': 'Seeding',
'stalledUP': 'Seeding',
'forcedUP': 'Seeding',
'queuedUP': 'Queued',
'queuedDL': 'Queued',
'checkingUP': 'Checking',
'checkingDL': 'Checking',
'checkingResumeData': 'Checking',
'moving': 'Moving',
'pausedUP': 'Paused',
'pausedDL': 'Paused',
'stoppedUP': 'Stopped',
'stoppedDL': 'Stopped',
'error': 'Error',
'missingFiles': 'Error',
'unknown': 'Unknown'
};
const status = stateMap[torrent.state] || torrent.state;
return {
id: torrent.hash,
title: torrent.name,
type: 'torrent',
client: 'qbittorrent',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: totalSize,
downloaded: downloadedSize,
speed: torrent.dlspeed,
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
category: torrent.category || undefined,
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
savePath: torrent.content_path || torrent.save_path || undefined,
addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined,
raw: torrent
};
}
// Reset fallback flag (called by registry at start of each poll cycle)
resetFallbackFlag() {
this.fallbackThisCycle = false;
}
}
module.exports = QBittorrentClient;
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const xmlrpc = require('xmlrpc');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
/**
* rTorrent download client implementation.
* Communicates via XML-RPC over HTTP.
* Supports HTTP Basic Auth when username/password are configured.
* The URL field must include the full XML-RPC endpoint path (e.g., http://rtorrent.local:8080/RPC2 or https://user.whatbox.ca/xmlrpc).
*/
class RTorrentClient extends DownloadClient {
constructor(instance) {
super(instance);
this._createClient();
}
_createClient() {
const clientOptions = { url: this.url };
if (this.username && this.password) {
clientOptions.headers = {
Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`
};
}
this.client = xmlrpc.createClient(clientOptions);
}
getClientType() {
return 'rtorrent';
}
async testConnection() {
try {
await this._methodCall('system.client_version');
logToFile(`[rtorrent:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
/**
* Wrap xmlrpc methodCall in a Promise.
* @param {string} method - XML-RPC method name
* @param {Array} params - Method parameters
* @returns {Promise<any>}
*/
_methodCall(method, params = []) {
return new Promise((resolve, reject) => {
this.client.methodCall(method, params, (error, value) => {
if (error) {
reject(error);
} else {
resolve(value);
}
});
});
}
async getActiveDownloads() {
try {
const torrents = await this._methodCall('d.multicall2', [
'',
'd.hash=',
'd.name=',
'd.size_bytes=',
'd.completed_bytes=',
'd.down.rate=',
'd.up.rate=',
'd.state=',
'd.is_active=',
'd.is_hash_checking=',
'd.directory=',
'd.custom1='
]);
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const [downRate, upRate] = await Promise.all([
this._methodCall('throttle.global_down.rate'),
this._methodCall('throttle.global_up.rate')
]);
return {
globalDownRate: downRate,
globalUpRate: upRate
};
} catch (error) {
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
const [
hash,
name,
sizeBytes,
completedBytes,
downRate,
upRate,
state,
isActive,
isHashChecking,
directory,
custom1
] = torrent;
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
// Calculate ETA when actively downloading
let eta = null;
if (status === 'Downloading' && downRate > 0 && completedBytes < sizeBytes) {
eta = Math.round((sizeBytes - completedBytes) / downRate);
}
const arrInfo = this._extractArrInfo(name);
return {
id: hash,
title: name,
type: 'torrent',
client: 'rtorrent',
instanceId: this.id,
instanceName: this.name,
status,
progress,
size: sizeBytes,
downloaded: completedBytes,
speed: status === 'Seeding' ? upRate : downRate,
eta,
category: custom1 || undefined,
tags: custom1 ? [custom1] : [],
savePath: directory || undefined,
addedOn: undefined, // rtorrent does not expose added time via multicall2
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
_mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes) {
if (isHashChecking === 1) {
return 'Checking';
}
if (state === 0) {
return 'Stopped';
}
if (isActive === 1) {
return completedBytes >= sizeBytes && sizeBytes > 0 ? 'Seeding' : 'Downloading';
}
return 'Paused';
}
_extractArrInfo(filename) {
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = RTorrentClient;
+239
View File
@@ -0,0 +1,239 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class SABnzbdClient extends DownloadClient {
constructor(instance) {
super(instance);
}
getClientType() {
return 'sabnzbd';
}
async testConnection() {
try {
const response = await this.makeRequest('', { mode: 'version' });
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
return true;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(additionalParams = {}, config = {}) {
const params = {
output: 'json',
apikey: this.apiKey,
...additionalParams
};
try {
const response = await axios.get(`${this.url}/api`, {
params,
...config
});
return response;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] API request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 })
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
const downloads = [];
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
for (const slot of queueData.queue.slots) {
downloads.push(this.normalizeDownload(slot, 'queue'));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history'));
}
}
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest({ mode: 'queue' });
const queueData = response.data.queue;
if (!queueData) return null;
return {
status: queueData.status,
speed: queueData.speed,
kbpersec: queueData.kbpersec,
sizeleft: queueData.sizeleft,
mbleft: queueData.mbleft,
mb: queueData.mb,
diskspace1: queueData.diskspace1,
diskspace2: queueData.diskspace2,
loadavg: queueData.loadavg,
pause_int: queueData.pause_int
};
} catch (error) {
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(slot, source) {
const isHistory = source === 'history';
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
'Paused': 'Paused',
'Waiting': 'Queued',
'Completed': 'Completed',
'Failed': 'Error',
'Verifying': 'Checking',
'Extracting': 'Extracting',
'Moving': 'Moving',
'QuickCheck': 'Checking',
'Repairing': 'Repairing'
};
const status = statusMap[slot.status] || slot.status;
// Calculate progress
let progress = 0;
let downloaded = 0;
let size = 0;
if (slot.mb && slot.mbleft !== undefined) {
size = slot.mb * 1024 * 1024; // Convert MB to bytes
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 0;
} else if (slot.size) {
// Try to parse size string (e.g., "1.5 GB")
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
if (sizeMatch) {
const [, sizeValue, sizeUnit] = sizeMatch;
const multiplier = this.getUnitMultiplier(sizeUnit);
size = parseFloat(sizeValue) * multiplier;
if (slot.sizeleft) {
const leftMatch = slot.sizeleft.match(/^([\d.]+)\s*(\w+)$/i);
if (leftMatch) {
const [, leftValue, leftUnit] = leftMatch;
const leftMultiplier = this.getUnitMultiplier(leftUnit);
downloaded = size - (parseFloat(leftValue) * leftMultiplier);
progress = size > 0 ? (downloaded / size) * 100 : 0;
}
}
}
}
// Extract Sonarr/Radarr info from nzb_name if present
const arrInfo = this.extractArrInfo(slot.nzb_name || slot.filename || '');
return {
id: slot.nzo_id || slot.id,
title: slot.filename || slot.nzb_name || 'Unknown',
type: 'usenet',
client: 'sabnzbd',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: { ...slot, source }
};
}
getUnitMultiplier(unit) {
const unitMap = {
'b': 1,
'byte': 1,
'bytes': 1,
'kb': 1024,
'k': 1024,
'mb': 1024 * 1024,
'm': 1024 * 1024,
'gb': 1024 * 1024 * 1024,
'g': 1024 * 1024 * 1024,
'tb': 1024 * 1024 * 1024 * 1024,
't': 1024 * 1024 * 1024 * 1024
};
return unitMap[unit.toLowerCase()] || 1;
}
calculateEta(timeLeft) {
if (!timeLeft || timeLeft === '0:00' || timeLeft === 'unknown') {
return null;
}
// Parse time in various formats: "0:05:30", "15:30", "330"
const parts = timeLeft.split(':').reverse();
let totalSeconds = 0;
if (parts.length === 1) {
// Just seconds
totalSeconds = parseInt(parts[0], 10);
} else if (parts.length === 2) {
// MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60;
} else if (parts.length === 3) {
// HH:MM:SS
totalSeconds = parseInt(parts[0], 10) + parseInt(parts[1], 10) * 60 + parseInt(parts[2], 10) * 3600;
}
return isNaN(totalSeconds) ? null : totalSeconds;
}
extractArrInfo(filename) {
// Try to extract Sonarr/Radarr info from filename patterns
// This is a simple implementation - could be enhanced with regex patterns
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = SABnzbdClient;
+181
View File
@@ -0,0 +1,181 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const DownloadClient = require('./DownloadClient');
const { logToFile } = require('../utils/logger');
class TransmissionClient extends DownloadClient {
constructor(instance) {
super(instance);
this.sessionId = null;
this.rpcUrl = `${this.url}/transmission/rpc`;
}
getClientType() {
return 'transmission';
}
async testConnection() {
try {
await this.makeRequest('session-get');
logToFile(`[Transmission:${this.name}] Connection test successful`);
return true;
} catch (error) {
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
return false;
}
}
async makeRequest(method, arguments_ = {}, config = {}) {
const payload = {
method,
arguments: arguments_
};
const headers = {
'Content-Type': 'application/json'
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
try {
const response = await axios.post(this.rpcUrl, payload, {
headers,
...config
});
if (response.data.result !== 'success') {
throw new Error(`Transmission RPC error: ${response.data.result}`);
}
return response;
} catch (error) {
// Handle session ID conflict (409 Conflict)
if (error.response && error.response.status === 409) {
const sessionId = error.response.headers['x-transmission-session-id'];
if (sessionId) {
this.sessionId = sessionId;
logToFile(`[Transmission:${this.name}] Updated session ID`);
return this.makeRequest(method, arguments_, config);
}
}
logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`);
throw error;
}
}
async getActiveDownloads() {
try {
// Get all torrents with detailed fields
const response = await this.makeRequest('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
const torrents = response.data.arguments.torrents || [];
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
return torrents.map(torrent => this.normalizeDownload(torrent));
} catch (error) {
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
async getClientStatus() {
try {
const response = await this.makeRequest('session-get');
const sessionStats = await this.makeRequest('session-stats');
return {
session: response.data.arguments,
stats: sessionStats.data.arguments
};
} catch (error) {
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
return null;
}
}
normalizeDownload(torrent) {
// Map Transmission status codes to normalized status
const statusMap = {
0: 'Stopped', // TORRENT_STOPPED
1: 'Queued', // TORRENT_CHECK_WAIT
2: 'Checking', // TORRENT_CHECK
3: 'Queued', // TORRENT_DOWNLOAD_WAIT
4: 'Downloading', // TORRENT_DOWNLOAD
5: 'Queued', // TORRENT_SEED_WAIT
6: 'Seeding', // TORRENT_SEED
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
};
const status = statusMap[torrent.status] || 'Unknown';
// Calculate progress and sizes
const progress = torrent.percentDone * 100;
const size = torrent.totalSize;
const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone;
// Handle ETA - Transmission uses -1 for unknown, -2 for infinite
let eta = null;
if (torrent.eta >= 0) {
eta = torrent.eta;
}
// Extract category/labels
const labels = torrent.labels || [];
const category = labels.length > 0 ? labels[0] : undefined;
// Try to extract Sonarr/Radarr info from name
const arrInfo = this.extractArrInfo(torrent.name);
return {
id: torrent.hashString,
title: torrent.name,
type: 'torrent',
client: 'transmission',
instanceId: this.id,
instanceName: this.name,
status: status,
progress: Math.round(progress),
size: size,
downloaded: downloaded,
speed: torrent.rateDownload,
eta: eta,
category: category,
tags: labels,
savePath: torrent.downloadDir || undefined,
addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
arrType: arrInfo.type,
raw: torrent
};
}
extractArrInfo(filename) {
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
// Look for patterns like "Series Name - S01E02 - Episode Title"
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
if (seriesMatch) {
return { type: 'series' };
}
// Look for movie year patterns like "Movie Title (2023)"
const movieMatch = filename.match(/\((\d{4})\)/);
if (movieMatch && !seriesMatch) {
return { type: 'movie' };
}
return {};
}
}
module.exports = TransmissionClient;
+35 -2
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
@@ -8,6 +9,8 @@ const fs = require('fs');
const http = require('http');
const https = require('https');
require('dotenv').config();
require('./utils/loadSecrets')();
const { version } = require('../package.json');
// Setup logging with levels
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
@@ -83,6 +86,7 @@ const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config');
// ---------------------------------------------------------------------------
// Startup environment validation
@@ -93,11 +97,16 @@ if (!cookieSecret && process.env.NODE_ENV === 'production') {
process.exit(1);
} else if (!cookieSecret) {
console.warn('[Security] COOKIE_SECRET not set — unsigned cookies (dev only)');
} else if (cookieSecret.length < 32) {
console.warn('[Security] COOKIE_SECRET is shorter than 32 characters — use openssl rand -hex 32');
}
if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
console.error('[Config] EMBY_URL is required');
process.exit(1);
}
if (process.env.EMBY_URL) {
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
}
const app = express();
const PORT = process.env.PORT || 3001;
@@ -188,7 +197,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
res.json({ status: 'ok', uptime: process.uptime(), version });
});
app.get('/ready', (req, res) => {
@@ -300,7 +309,7 @@ const isSnakeoil = TLS_ENABLED &&
server.listen(PORT, () => {
console.log(`=================================`);
console.log(` sofarr - Your Downloads Dashboard`);
console.log(` sofarr v${version} - Your Downloads Dashboard`);
console.log(` Server running on ${protocol}://localhost:${PORT}`);
if (tlsCredentials && isSnakeoil) {
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
@@ -317,3 +326,27 @@ server.listen(PORT, () => {
console.log(`=================================`);
startPoller();
});
// ---------------------------------------------------------------------------
// Graceful shutdown — handle SIGTERM (Docker stop) and SIGINT (Ctrl+C)
// Stop the poller, close the HTTP server (stops accepting new connections),
// then let Node drain existing keep-alive connections and exit cleanly.
// ---------------------------------------------------------------------------
const { stopPoller } = require('./utils/poller');
function shutdown(signal) {
console.log(`[Server] ${signal} received — shutting down gracefully`);
stopPoller();
server.close(() => {
console.log('[Server] HTTP server closed');
process.exit(0);
});
// Force exit after 10 s if connections don't drain
setTimeout(() => {
console.error('[Server] Forced exit after 10 s timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+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');
+118 -4
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
@@ -94,6 +95,23 @@ function getRadarrLink(movie) {
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) {
@@ -321,7 +339,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = 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 = canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj);
}
}
@@ -363,7 +388,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = 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 = canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj);
}
}
@@ -520,7 +552,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = 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 = canBlocklist(download, isAdmin);
userDownloads.push(download);
continue; // Skip to next torrent
}
@@ -555,7 +594,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = 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 = canBlocklist(download, isAdmin);
userDownloads.push(download);
continue; // Skip to next torrent
}
@@ -893,7 +939,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = 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 = canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj);
}
}
@@ -912,7 +959,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = 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 = canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj);
}
}
@@ -979,7 +1027,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = 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 = canBlocklist(download, isAdmin);
userDownloads.push(download); continue;
}
}
@@ -995,7 +1044,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = 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 = canBlocklist(download, isAdmin);
userDownloads.push(download); continue;
}
}
@@ -1059,4 +1109,68 @@ router.get('/stream', requireAuth, async (req, res) => {
});
});
/**
* POST /api/dashboard/blocklist-search
*
* Admin-only. Removes a queue item from Sonarr/Radarr with blocklist=true
* (so the release is not grabbed again), then immediately triggers a new
* automatic search for the same episode/movie.
*
* Body: {
* arrQueueId: number — Sonarr/Radarr queue record id
* arrType: 'sonarr'|'radarr'
* arrInstanceUrl: string — base URL of the arr instance
* arrInstanceKey: string — API key for the arr instance
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
* arrContentType: 'episode'|'movie'
* }
*/
router.post('/blocklist-search', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (arrType !== 'sonarr' && arrType !== 'radarr') {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
}
const headers = { 'X-Api-Key': arrInstanceKey };
// Step 1: Remove from queue with blocklist=true
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
headers,
params: { removeFromClient: true, blocklist: true }
});
// Step 2: Trigger a new automatic search
let commandBody;
if (arrType === 'sonarr' && arrContentType === 'episode') {
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
} else if (arrType === 'radarr' && arrContentType === 'movie') {
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
}
if (commandBody) {
await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers });
}
// Invalidate the poll cache so the next SSE push reflects the removed item
const { pollAllServices } = require('../utils/poller');
pollAllServices().catch(() => {});
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
res.json({ ok: true });
} catch (err) {
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) });
}
});
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();
+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);
+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();
+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();
+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();
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
class MemoryCache {
+53 -5
View File
@@ -1,5 +1,28 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
// Validate that a configured service URL is well-formed and uses http(s).
// Emits a warning (never throws) so a misconfigured instance degrades
// gracefully rather than crashing the whole server.
function validateInstanceUrl(url, instanceId) {
if (!url || typeof url !== 'string') {
logToFile(`[Config] WARNING: instance "${instanceId}" has no URL configured`);
return false;
}
let parsed;
try {
parsed = new URL(url);
} catch {
logToFile(`[Config] WARNING: instance "${instanceId}" has an invalid URL: "${url}"`);
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
logToFile(`[Config] WARNING: instance "${instanceId}" URL must use http or https, got "${parsed.protocol}"`);
return false;
}
return true;
}
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
// Try to parse JSON array format first
if (envVar) {
@@ -9,10 +32,11 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
const instances = JSON.parse(cleaned);
if (Array.isArray(instances) && instances.length > 0) {
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
return instances.map((inst, idx) => ({
...inst,
id: inst.name || `instance-${idx + 1}`
}));
return instances.map((inst, idx) => {
const id = inst.name || `instance-${idx + 1}`;
validateInstanceUrl(inst.url, id);
return { ...inst, id };
});
}
} catch (err) {
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
@@ -22,6 +46,7 @@ function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPass
// Fall back to legacy single-instance format
if (legacyUrl && legacyKey) {
logToFile(`[Config] Using legacy single-instance format`);
validateInstanceUrl(legacyUrl, 'default');
return [{
id: 'default',
name: 'Default',
@@ -69,10 +94,33 @@ 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
);
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances,
getQbittorrentInstances,
parseInstances
getTransmissionInstances,
getRtorrentInstances,
parseInstances,
validateInstanceUrl
};
+254
View File
@@ -0,0 +1,254 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const { logToFile } = require('./logger');
const {
getSABnzbdInstances,
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances
} = require('./config');
// Import client classes
const SABnzbdClient = require('../clients/SABnzbdClient');
const QBittorrentClient = require('../clients/QBittorrentClient');
const TransmissionClient = require('../clients/TransmissionClient');
const RTorrentClient = require('../clients/RTorrentClient');
// Client type mapping
const clientClasses = {
sabnzbd: SABnzbdClient,
qbittorrent: QBittorrentClient,
transmission: TransmissionClient,
rtorrent: RTorrentClient
};
/**
* Registry and factory for download clients
*/
class DownloadClientRegistry {
constructor() {
this.clients = new Map();
this.initialized = false;
}
/**
* Initialize all configured download clients
*/
async initialize() {
if (this.initialized) {
return;
}
logToFile('[DownloadClientRegistry] Initializing download clients...');
// Get all instance configurations
const sabnzbdInstances = getSABnzbdInstances();
const qbittorrentInstances = getQbittorrentInstances();
const transmissionInstances = getTransmissionInstances();
const rtorrentInstances = getRtorrentInstances();
// Create client instances
const instanceConfigs = [
...sabnzbdInstances.map(inst => ({ ...inst, type: 'sabnzbd' })),
...qbittorrentInstances.map(inst => ({ ...inst, type: 'qbittorrent' })),
...transmissionInstances.map(inst => ({ ...inst, type: 'transmission' })),
...rtorrentInstances.map(inst => ({ ...inst, type: 'rtorrent' }))
];
for (const config of instanceConfigs) {
try {
const ClientClass = clientClasses[config.type];
if (!ClientClass) {
logToFile(`[DownloadClientRegistry] Unknown client type: ${config.type}`);
continue;
}
const client = new ClientClass(config);
this.clients.set(config.id, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
} catch (error) {
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
}
}
this.initialized = true;
logToFile(`[DownloadClientRegistry] Initialized ${this.clients.size} download clients`);
}
/**
* Get all registered clients
* @returns {Array<DownloadClient>} Array of client instances
*/
getAllClients() {
return Array.from(this.clients.values());
}
/**
* Get client by instance ID
* @param {string} instanceId - The instance ID
* @returns {DownloadClient|null} Client instance or null if not found
*/
getClient(instanceId) {
return this.clients.get(instanceId) || null;
}
/**
* Get clients by type
* @param {string} type - Client type ('sabnzbd', 'qbittorrent', 'transmission')
* @returns {Array<DownloadClient>} Array of client instances
*/
getClientsByType(type) {
return this.getAllClients().filter(client => client.getClientType() === type);
}
/**
* Get active downloads from all clients
* @returns {Promise<Array<NormalizedDownload>>} Array of all downloads
*/
async getAllDownloads() {
const clients = this.getAllClients();
if (clients.length === 0) {
return [];
}
// Reset fallback flags for qBittorrent clients
for (const client of clients) {
if (client.resetFallbackFlag) {
client.resetFallbackFlag();
}
}
// Fetch downloads from all clients in parallel
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const downloads = await client.getActiveDownloads();
logToFile(`[DownloadClientRegistry] ${client.name}: ${downloads.length} downloads`);
return downloads;
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
return [];
}
})
);
// Flatten and return all downloads
const allDownloads = results
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value);
logToFile(`[DownloadClientRegistry] Total downloads from all clients: ${allDownloads.length}`);
return allDownloads;
}
/**
* Get downloads grouped by client type (for backward compatibility)
* @returns {Promise<Object>} Downloads grouped by client type
*/
async getDownloadsByClientType() {
const clients = this.getAllClients();
const result = {};
// Group by client type
for (const client of clients) {
const type = client.getClientType();
if (!result[type]) {
result[type] = [];
}
try {
const downloads = await client.getActiveDownloads();
result[type].push(...downloads);
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
}
}
return result;
}
/**
* Test connection to all clients
* @returns {Promise<Array<Object>>} Array of connection test results
*/
async testAllConnections() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const success = await client.testConnection();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success,
error: null
};
} catch (error) {
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
success: false,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
/**
* Get client status information from all clients
* @returns {Promise<Array<Object>>} Array of client status objects
*/
async getAllClientStatuses() {
const clients = this.getAllClients();
const results = await Promise.allSettled(
clients.map(async (client) => {
try {
const status = await client.getClientStatus();
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status
};
} catch (error) {
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
return {
instanceId: client.getInstanceId(),
instanceName: client.name,
clientType: client.getClientType(),
status: null,
error: error.message
};
}
})
);
return results
.filter(result => result.status === 'fulfilled')
.map(result => result.value);
}
}
// Create singleton instance
const registry = new DownloadClientRegistry();
module.exports = {
DownloadClientRegistry,
registry,
// Convenience functions
initializeClients: () => registry.initialize(),
getAllClients: () => registry.getAllClients(),
getClient: (instanceId) => registry.getClient(instanceId),
getClientsByType: (type) => registry.getClientsByType(type),
getAllDownloads: () => registry.getAllDownloads(),
getDownloadsByClientType: () => registry.getDownloadsByClientType(),
testAllConnections: () => registry.testAllConnections(),
getAllClientStatuses: () => registry.getAllClientStatuses()
};
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const cache = require('./cache');
const { getSonarrInstances, getRadarrInstances } = require('./config');
+52
View File
@@ -0,0 +1,52 @@
// 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.
// This follows the standard *_FILE convention used by official Docker images.
//
// Supported secrets:
// COOKIE_SECRET_FILE → COOKIE_SECRET
// EMBY_API_KEY_FILE → EMBY_API_KEY
// SABNZBD_API_KEY_FILE → SABNZBD_API_KEY (legacy single-instance)
// SONARR_API_KEY_FILE → SONARR_API_KEY (legacy single-instance)
// RADARR_API_KEY_FILE → RADARR_API_KEY (legacy single-instance)
// QBITTORRENT_PASSWORD_FILE → QBITTORRENT_PASSWORD (legacy single-instance)
//
// For multi-instance JSON arrays the secret values must be embedded in the
// JSON string itself; file-based loading is for the legacy single-key format.
const fs = require('fs');
const SECRET_MAPPINGS = [
'COOKIE_SECRET',
'EMBY_API_KEY',
'SABNZBD_API_KEY',
'SONARR_API_KEY',
'RADARR_API_KEY',
'QBITTORRENT_PASSWORD',
];
function loadSecrets() {
for (const key of SECRET_MAPPINGS) {
const fileEnv = `${key}_FILE`;
const filePath = process.env[fileEnv];
if (!filePath) continue;
if (process.env[key]) {
console.warn(`[Secrets] Both ${key} and ${fileEnv} are set — ${fileEnv} takes precedence`);
}
try {
const value = fs.readFileSync(filePath, 'utf8').trim();
if (!value) {
console.warn(`[Secrets] ${fileEnv} points to an empty file: ${filePath}`);
continue;
}
process.env[key] = value;
console.log(`[Secrets] Loaded ${key} from ${fileEnv}`);
} catch (err) {
console.error(`[Secrets] Failed to read ${fileEnv} (${filePath}): ${err.message}`);
process.exit(1);
}
}
}
module.exports = loadSecrets;
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const fs = require('fs');
const path = require('path');
+79 -38
View File
@@ -1,8 +1,8 @@
// 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 {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('./config');
@@ -38,28 +38,18 @@ async function pollAllServices() {
const start = Date.now();
try {
const sabInstances = getSABnzbdInstances();
// Ensure download clients are initialized
await initializeClients();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// 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('Download Clients', async () => {
const downloadsByType = await getDownloadsByClientType();
return downloadsByType;
}),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
@@ -112,19 +102,14 @@ async function pollAllServices() {
return { instance: inst.id, data: [] };
})
))),
timed('qBittorrent', () => getTorrents().catch(err => {
console.error(`[Poller] qBittorrent error:`, err.message);
return [];
}))
]);
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
@@ -139,18 +124,69 @@ 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;
// 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
}))
};
const sabHistoryLegacy = {
slots: sabHistory.map(d => ({
nzo_id: d.id,
filename: d.title,
status: d.status,
mb: d.size / (1024 * 1024),
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
}))
};
// Extract status from first SABnzbd download if available
const firstSabDownload = sabQueue[0];
const sabStatus = firstSabDownload ? {
status: 'Active',
speed: firstSabDownload.speed,
kbpersec: firstSabDownload.speed / 1024
} : { status: 'Idle', speed: 0, kbpersec: 0 };
cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
...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);
@@ -159,8 +195,11 @@ async function pollAllServices() {
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;
});
})
@@ -174,8 +213,11 @@ async function pollAllServices() {
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;
});
})
@@ -185,8 +227,7 @@ async function pollAllServices() {
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), 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`);
+41 -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) {
@@ -204,12 +119,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,
+7 -1
View File
@@ -1,3 +1,4 @@
// 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)
@@ -7,13 +8,18 @@ const HEADER_PATTERN = /(?:x-api-key|x-mediabrowser-token|x-emby-authorization|a
const BEARER_PATTERN = /bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
// Basic auth credentials in URLs (http://user:pass@host)
const BASIC_AUTH_URL_PATTERN = /\/\/[^:@/\s]+:[^@/\s]+@/gi;
// Redact only the host:port authority portion of URLs, preserving path/query so
// other patterns (QUERY_SECRET_PATTERN etc.) can still act on them.
// Negative lookahead skips URLs already handled by BASIC_AUTH_URL_PATTERN.
const HOST_PATTERN = /(https?:\/\/)(?!\[REDACTED\]@)([^\s/?#]+)/gi;
function sanitizeError(err) {
let msg = (err && err.message) ? err.message : String(err);
msg = msg.replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
msg = msg.replace(HEADER_PATTERN, (m) => m.split(/[\s:]/)[0] + ':[REDACTED]');
msg = msg.replace(BEARER_PATTERN, 'bearer [REDACTED]');
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@');
msg = msg.replace(BASIC_AUTH_URL_PATTERN, '//[REDACTED]@'); // must run before HOST_PATTERN
msg = msg.replace(HOST_PATTERN, '$1[HOST]');
// Never leak stack traces to API responses
return msg;
}
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Persistent token store backed by a JSON file.
*
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for authentication routes.
*
+300
View File
@@ -0,0 +1,300 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
initializeClients,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
registry
} from '../../server/utils/downloadClients.js';
import axios from 'axios';
import { vi } from 'vitest';
// Mock environment variables for testing
process.env.SABNZBD_INSTANCES = JSON.stringify([
{
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
}
]);
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb',
name: 'Test qBittorrent',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
}
]);
process.env.TRANSMISSION_INSTANCES = JSON.stringify([
{
id: 'test-trans',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
}
]);
process.env.RTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080/RPC2',
username: 'rtorrent',
password: 'rtorrent'
}
]);
// Mock axios to prevent actual network calls
vi.mock('axios', () => {
const mockAxios = vi.fn();
mockAxios.post = vi.fn();
mockAxios.get = vi.fn();
return {
default: mockAxios,
post: vi.fn(),
get: vi.fn()
};
});
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('Download Clients Integration Tests', () => {
beforeEach(() => {
registry.initialized = false;
registry.clients.clear();
vi.clearAllMocks();
});
describe('Client Initialization', () => {
it('should initialize all configured client types', async () => {
await initializeClients();
// The registry should have clients for all three types
const downloadsByType = await getDownloadsByClientType();
// Should have keys for each client type (even if empty due to mocked failures)
expect(typeof downloadsByType).toBe('object');
});
it('should handle missing environment variables gracefully', async () => {
// Temporarily clear environment variables
const originalSab = process.env.SABNZBD_INSTANCES;
const originalQb = process.env.QBITTORRENT_INSTANCES;
const originalTrans = process.env.TRANSMISSION_INSTANCES;
const originalRt = process.env.RTORRENT_INSTANCES;
delete process.env.SABNZBD_INSTANCES;
delete process.env.QBITTORRENT_INSTANCES;
delete process.env.TRANSMISSION_INSTANCES;
delete process.env.RTORRENT_INSTANCES;
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
expect(Object.keys(downloadsByType)).toHaveLength(0);
// Restore environment variables
process.env.SABNZBD_INSTANCES = originalSab;
process.env.QBITTORRENT_INSTANCES = originalQb;
process.env.TRANSMISSION_INSTANCES = originalTrans;
process.env.RTORRENT_INSTANCES = originalRt;
});
});
describe('Download Aggregation', () => {
it('should aggregate downloads from multiple client types', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
// Should return downloads grouped by type
expect(typeof downloadsByType).toBe('object');
// Should return flattened array of all downloads
expect(Array.isArray(allDownloads)).toBe(true);
// All downloads should have required normalized fields
allDownloads.forEach(download => {
expect(download).toHaveProperty('id');
expect(download).toHaveProperty('title');
expect(download).toHaveProperty('type');
expect(download).toHaveProperty('client');
expect(download).toHaveProperty('instanceId');
expect(download).toHaveProperty('instanceName');
expect(download).toHaveProperty('status');
expect(download).toHaveProperty('progress');
expect(download).toHaveProperty('size');
expect(download).toHaveProperty('downloaded');
expect(download).toHaveProperty('speed');
expect(download).toHaveProperty('raw');
});
});
it('should maintain type consistency across clients', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Check that each client type returns consistent data structure
Object.entries(downloadsByType).forEach(([clientType, downloads]) => {
if (downloads.length > 0) {
downloads.forEach(download => {
expect(download.client).toBe(clientType);
expect(download.type).toMatch(/^(usenet|torrent)$/);
expect(typeof download.progress).toBe('number');
expect(download.progress).toBeGreaterThanOrEqual(0);
expect(download.progress).toBeLessThanOrEqual(100);
expect(typeof download.size).toBe('number');
expect(typeof download.downloaded).toBe('number');
expect(typeof download.speed).toBe('number');
});
}
});
});
});
describe('Connection Testing', () => {
it('should test connections for all configured clients', async () => {
await initializeClients();
const results = await testAllConnections();
expect(Array.isArray(results)).toBe(true);
results.forEach(result => {
expect(result).toHaveProperty('instanceId');
expect(result).toHaveProperty('instanceName');
expect(result).toHaveProperty('clientType');
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
});
});
it('should handle connection failures gracefully', async () => {
// This test verifies that connection failures don't crash the system
await initializeClients();
const results = await testAllConnections();
// Should still return results even if connections fail
expect(results.length).toBeGreaterThan(0);
});
});
describe('Error Handling and Resilience', () => {
it('should handle individual client failures without affecting others', async () => {
await initializeClients();
// Even if some clients fail, others should still work
const downloadsByType = await getDownloadsByClientType();
const allDownloads = await getAllDownloads();
expect(typeof downloadsByType).toBe('object');
expect(Array.isArray(allDownloads)).toBe(true);
});
it('should handle malformed configuration gracefully', async () => {
// Test with malformed JSON
const originalSab = process.env.SABNZBD_INSTANCES;
process.env.SABNZBD_INSTANCES = 'invalid-json{';
// Should not throw an error
await expect(initializeClients()).resolves.not.toThrow();
// Restore
process.env.SABNZBD_INSTANCES = originalSab;
});
it('should handle network timeouts and errors', async () => {
await initializeClients();
// Mock network failures by setting up axios to reject
axios.get.mockRejectedValue(new Error('Network timeout'));
axios.post.mockRejectedValue(new Error('Network timeout'));
// Should handle errors gracefully and return empty results
const downloads = await getAllDownloads();
expect(Array.isArray(downloads)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should maintain compatibility with existing cache structure', async () => {
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// SABnzbd downloads should have raw data for legacy compatibility
if (downloadsByType.sabnzbd && downloadsByType.sabnzbd.length > 0) {
downloadsByType.sabnzbd.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.source).toMatch(/^(queue|history)$/);
});
}
// qBittorrent downloads should have raw data for legacy compatibility
if (downloadsByType.qbittorrent && downloadsByType.qbittorrent.length > 0) {
downloadsByType.qbittorrent.forEach(download => {
expect(download.raw).toBeTruthy();
expect(download.raw.hash).toBeTruthy(); // qBittorrent specific field
});
}
});
});
describe('Performance and Scalability', () => {
it('should handle multiple instances of the same client type', async () => {
// Configure multiple instances
process.env.QBITTORRENT_INSTANCES = JSON.stringify([
{
id: 'test-qb-1',
name: 'Test qBittorrent 1',
url: 'http://localhost:8080',
username: 'admin',
password: 'adminadmin'
},
{
id: 'test-qb-2',
name: 'Test qBittorrent 2',
url: 'http://localhost:8081',
username: 'admin',
password: 'adminadmin'
}
]);
await initializeClients();
const downloadsByType = await getDownloadsByClientType();
// Should aggregate downloads from both instances
expect(Array.isArray(downloadsByType.qbittorrent)).toBe(true);
// Each download should have correct instance information
downloadsByType.qbittorrent.forEach(download => {
expect(download.instanceId).toMatch(/^(test-qb-1|test-qb-2)$/);
expect(download.instanceName).toMatch(/^(Test qBittorrent 1|Test qBittorrent 2)$/);
});
});
it('should execute client requests in parallel', async () => {
const startTime = Date.now();
await initializeClients();
await getAllDownloads();
const endTime = Date.now();
const duration = endTime - startTime;
// This is a rough check - in a real scenario with actual network calls,
// parallel execution should be significantly faster than sequential
expect(duration).toBeGreaterThan(0);
});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for health and readiness endpoints.
*
+112
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 });
+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');
});
});
});
+423
View File
@@ -0,0 +1,423 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import RTorrentClient from '../../../server/clients/RTorrentClient.js';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('RTorrentClient', () => {
let client;
let mockConfig;
let mockMethodCall;
beforeEach(() => {
mockMethodCall = vi.fn();
mockConfig = {
id: 'test-rtorrent',
name: 'Test rTorrent',
url: 'http://localhost:8080',
username: 'rtorrent',
password: 'rtorrent'
};
client = new RTorrentClient(mockConfig);
// Mock the xmlrpc client's methodCall directly
client.client.methodCall = mockMethodCall;
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('rtorrent');
expect(client.getInstanceId()).toBe('test-rtorrent');
expect(client.name).toBe('Test rTorrent');
expect(client.url).toBe('http://localhost:8080');
});
it('should create xmlrpc client with correct URL', async () => {
expect(client.url).toBe('http://localhost:8080');
expect(client.client).toBeDefined();
});
it('should create xmlrpc client without auth when no credentials', () => {
const noAuthConfig = {
id: 'test-rtorrent-noauth',
name: 'Test rTorrent No Auth',
url: 'http://localhost:8080/RPC2'
};
const clientNoAuth = new RTorrentClient(noAuthConfig);
expect(clientNoAuth.client).toBeDefined();
});
it('should use whatbox.ca-style /xmlrpc path exactly as configured', () => {
const whatboxConfig = {
id: 'test-whatbox',
name: 'Whatbox',
url: 'https://user.whatbox.ca/xmlrpc',
username: 'user',
password: 'pass'
};
const clientWhatbox = new RTorrentClient(whatboxConfig);
expect(clientWhatbox.client).toBeDefined();
});
it('should use custom RPC path exactly as configured', () => {
const customConfig = {
id: 'test-custom',
name: 'Custom',
url: 'https://example.com/custom/rpc/path'
};
const clientCustom = new RTorrentClient(customConfig);
expect(clientCustom.client).toBeDefined();
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, '0.9.8');
});
const result = await client.testConnection();
expect(result).toBe(true);
});
it('should handle connection test failure', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Connection refused'));
});
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('getActiveDownloads', () => {
it('should fetch and normalize torrents', async () => {
const mockTorrents = [
[
'abc123def456',
'Test Torrent 1',
1000000000,
750000000,
1048576,
0,
1,
1,
0,
'/downloads/test',
'movies'
],
[
'def789abc012',
'Test Torrent 2',
2000000000,
2000000000,
0,
512000,
1,
1,
0,
'/downloads/complete',
'tv'
]
];
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, mockTorrents);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('abc123def456');
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[0].progress).toBe(75);
expect(downloads[0].category).toBe('movies');
expect(downloads[1].status).toBe('Seeding');
expect(downloads[1].category).toBe('tv');
});
it('should handle empty torrent list', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(null, []);
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle XML-RPC errors gracefully', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('XML-RPC fault'));
});
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('normalizeDownload', () => {
it('should normalize a downloading torrent', () => {
const torrent = [
'hash123',
'Downloading Torrent',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'hash123',
title: 'Downloading Torrent',
type: 'torrent',
client: 'rtorrent',
instanceId: 'test-rtorrent',
instanceName: 'Test rTorrent',
status: 'Downloading',
progress: 50,
size: 1000000000,
downloaded: 500000000,
speed: 1048576,
eta: 477,
category: undefined,
tags: [],
savePath: '/downloads',
addedOn: undefined,
arrQueueId: undefined,
arrType: undefined,
raw: torrent
});
});
it('should normalize a seeding torrent', () => {
const torrent = [
'hash456',
'Seeding Torrent',
500000000,
500000000,
0,
204800,
1,
1,
0,
'/downloads/complete',
'movies'
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Seeding');
expect(normalized.progress).toBe(100);
expect(normalized.speed).toBe(204800);
expect(normalized.eta).toBeNull();
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies']);
});
it('should normalize a paused torrent', () => {
const torrent = [
'hash789',
'Paused Torrent',
1000000000,
250000000,
0,
0,
1,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Paused');
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
});
it('should normalize a stopped torrent', () => {
const torrent = [
'hashabc',
'Stopped Torrent',
1000000000,
0,
0,
0,
0,
0,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Stopped');
});
it('should normalize a checking torrent', () => {
const torrent = [
'hashdef',
'Checking Torrent',
1000000000,
500000000,
0,
0,
1,
0,
1,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe('Checking');
});
it('should handle zero-size torrent', () => {
const torrent = [
'hash000',
'Zero Size',
0,
0,
0,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.progress).toBe(0);
expect(normalized.size).toBe(0);
expect(normalized.downloaded).toBe(0);
});
});
describe('Status Mapping', () => {
const testCases = [
{ state: 0, isActive: 0, isHashChecking: 0, completed: 0, size: 100, expected: 'Stopped' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 50, size: 100, expected: 'Downloading' },
{ state: 1, isActive: 1, isHashChecking: 0, completed: 100, size: 100, expected: 'Seeding' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 50, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 0, completed: 100, size: 100, expected: 'Paused' },
{ state: 1, isActive: 0, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' },
{ state: 1, isActive: 1, isHashChecking: 1, completed: 50, size: 100, expected: 'Checking' }
];
testCases.forEach(({ state, isActive, isHashChecking, completed, size, expected }) => {
it(`should map state=${state} isActive=${isActive} isHashChecking=${isHashChecking} to ${expected}`, () => {
const status = client._mapStatus(state, isActive, isHashChecking, completed, size);
expect(status).toBe(expected);
});
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = [
'hash123',
'Show Name - S01E02 - Episode Title',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = [
'hash456',
'Movie Title (2023) 1080p',
2000000000,
1000000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = [
'hash789',
'Generic File Name.mkv',
1000000000,
500000000,
1048576,
0,
1,
1,
0,
'/downloads',
''
];
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
describe('Client Status', () => {
it('should get client status', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
if (method === 'throttle.global_down.rate') {
callback(null, 1048576);
} else if (method === 'throttle.global_up.rate') {
callback(null, 512000);
}
});
const status = await client.getClientStatus();
expect(status).toEqual({
globalDownRate: 1048576,
globalUpRate: 512000
});
});
it('should handle status request errors', async () => {
mockMethodCall.mockImplementation((method, params, callback) => {
callback(new Error('Status error'));
});
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
+302
View File
@@ -0,0 +1,302 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import SABnzbdClient from '../../../server/clients/SABnzbdClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('SABnzbdClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-sab',
name: 'Test SABnzbd',
url: 'http://localhost:8080',
apiKey: 'test-api-key'
};
client = new SABnzbdClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('sabnzbd');
expect(client.getInstanceId()).toBe('test-sab');
expect(client.name).toBe('Test SABnzbd');
expect(client.url).toBe('http://localhost:8080');
expect(client.apiKey).toBe('test-api-key');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { version: '3.6.1' }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('', { mode: 'version' });
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('API Requests', () => {
it('should make API request with correct parameters', async () => {
nock('http://localhost:8080')
.get('/api')
.query({
output: 'json',
apikey: 'test-api-key',
mode: 'queue',
limit: 10
})
.reply(200, { result: 'success' });
const result = await client.makeRequest({ mode: 'queue', limit: 10 });
expect(result.data).toEqual({ result: 'success' });
});
it('should handle API request errors', async () => {
nock('http://localhost:8080')
.get('/api')
.query({ output: 'json', apikey: 'test-api-key', mode: 'queue' })
.replyWithError(new Error('API Error'));
await expect(client.makeRequest({ mode: 'queue' })).rejects.toThrow('API Error');
});
});
describe('Download Normalization', () => {
it('should normalize queue download correctly', () => {
const slot = {
nzo_id: 'test123',
filename: 'Test Movie.mkv',
status: 'Downloading',
progress: 75.5,
mb: 1000,
mbleft: 245,
kbpersec: 1024,
timeleft: '0:15:30',
cat: 'movies',
labels: 'movie,hd',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized).toEqual({
id: 'test123',
title: 'Test Movie.mkv',
type: 'usenet',
client: 'sabnzbd',
instanceId: 'test-sab',
instanceName: 'Test SABnzbd',
status: 'Downloading',
progress: 76,
size: 1000 * 1024 * 1024,
downloaded: 755 * 1024 * 1024,
speed: 1024 * 1024,
eta: 930, // 15 minutes 30 seconds
category: 'movies',
tags: ['movie', 'hd'],
savePath: undefined,
addedOn: '2022-01-01T00:00:00.000Z',
raw: { ...slot, source: 'queue' }
});
});
it('should normalize history download correctly', () => {
const slot = {
nzo_id: 'test456',
filename: 'Test Series S01E01.mkv',
status: 'Completed',
mb: 500,
mbleft: 0,
cat: 'tv',
added: 1640995200
};
const normalized = client.normalizeDownload(slot, 'history');
expect(normalized.status).toBe('Completed');
expect(normalized.progress).toBe(100);
expect(normalized.downloaded).toBe(500 * 1024 * 1024);
expect(normalized.speed).toBe(0);
expect(normalized.eta).toBeNull();
expect(normalized.raw.source).toBe('history');
});
it('should parse time strings correctly', () => {
const testCases = [
{ input: '0:05:30', expected: 330 }, // 5m 30s
{ input: '15:30', expected: 930 }, // 15m 30s
{ input: '330', expected: 330 }, // 330 seconds
{ input: 'unknown', expected: null }, // unknown
{ input: '0:00', expected: null } // zero
];
testCases.forEach(({ input, expected }) => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500,
timeleft: input
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.eta).toBe(expected);
});
});
it('should extract Sonarr/Radarr info from filename', () => {
const testCases = [
{ filename: 'Show Name - S01E02 - Episode Title', expectedType: 'series' },
{ filename: 'Movie Title (2023) 1080p', expectedType: 'movie' },
{ filename: 'Random File Name.mkv', expectedType: undefined }
];
testCases.forEach(({ filename, expectedType }) => {
const slot = {
nzo_id: 'test',
filename: filename,
status: 'Downloading',
progress: 50,
mb: 1000,
mbleft: 500
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.arrType).toBe(expectedType);
});
});
it('should handle size parsing from strings', () => {
const slot = {
nzo_id: 'test',
filename: 'Test',
status: 'Downloading',
progress: 50,
size: '1.5 GB',
sizeleft: '0.75 GB'
};
const normalized = client.normalizeDownload(slot, 'queue');
expect(normalized.size).toBe(1.5 * 1024 * 1024 * 1024);
expect(normalized.downloaded).toBe(0.75 * 1024 * 1024 * 1024);
expect(normalized.progress).toBe(50);
});
});
describe('Unit Multipliers', () => {
it('should return correct multipliers for different units', () => {
expect(client.getUnitMultiplier('b')).toBe(1);
expect(client.getUnitMultiplier('KB')).toBe(1024);
expect(client.getUnitMultiplier('mb')).toBe(1024 * 1024);
expect(client.getUnitMultiplier('GB')).toBe(1024 * 1024 * 1024);
expect(client.getUnitMultiplier('tb')).toBe(1024 * 1024 * 1024 * 1024);
expect(client.getUnitMultiplier('unknown')).toBe(1);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize downloads from queue and history', async () => {
const mockQueueResponse = {
data: {
queue: {
slots: [
{ nzo_id: 'queue1', filename: 'Queue Item', status: 'Downloading' }
]
}
}
};
const mockHistoryResponse = {
data: {
history: {
slots: [
{ nzo_id: 'hist1', filename: 'History Item', status: 'Completed' }
]
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockQueueResponse)
.mockResolvedValueOnce(mockHistoryResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'queue' });
expect(client.makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 10 });
expect(downloads).toHaveLength(2);
expect(downloads[0].id).toBe('queue1');
expect(downloads[1].id).toBe('hist1');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status', async () => {
const mockResponse = {
data: {
queue: {
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const status = await client.getClientStatus();
expect(status).toEqual({
status: 'Active',
speed: 1048576,
kbpersec: 1024,
sizeleft: 500000000,
mbleft: 500
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
});
@@ -0,0 +1,436 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import TransmissionClient from '../../../server/clients/TransmissionClient.js';
import nock from 'nock';
import { vi } from 'vitest';
vi.mock('../../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
describe('TransmissionClient', () => {
let client;
let mockConfig;
beforeEach(() => {
mockConfig = {
id: 'test-transmission',
name: 'Test Transmission',
url: 'http://localhost:9091',
username: 'transmission',
password: 'transmission'
};
client = new TransmissionClient(mockConfig);
// Clear all mocks
vi.clearAllMocks();
});
describe('Constructor', () => {
it('should initialize with correct properties', () => {
expect(client.getClientType()).toBe('transmission');
expect(client.getInstanceId()).toBe('test-transmission');
expect(client.name).toBe('Test Transmission');
expect(client.url).toBe('http://localhost:9091');
expect(client.sessionId).toBeNull();
expect(client.rpcUrl).toBe('http://localhost:9091/transmission/rpc');
});
});
describe('Connection Test', () => {
it('should test connection successfully', async () => {
const mockResponse = {
data: { result: 'success', arguments: {} }
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const result = await client.testConnection();
expect(result).toBe(true);
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
});
it('should handle connection test failure', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Connection failed'));
const result = await client.testConnection();
expect(result).toBe(false);
});
});
describe('RPC Requests', () => {
it('should make RPC request with session ID', async () => {
client.sessionId = 'test-session-id';
nock('http://localhost:9091')
.post('/transmission/rpc', {
method: 'torrent-get',
arguments: { fields: ['id', 'name'] }
})
.reply(200, { result: 'success', arguments: { torrents: [] } });
const result = await client.makeRequest('torrent-get', { fields: ['id', 'name'] });
expect(result.data).toEqual({ result: 'success', arguments: { torrents: [] } });
});
it('should handle session ID conflict (409)', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(409, {}, { 'x-transmission-session-id': 'new-session-id' });
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'session-get', arguments: {} })
.reply(200, { result: 'success', arguments: {} });
const result = await client.makeRequest('session-get');
expect(client.sessionId).toBe('new-session-id');
expect(result.data).toEqual({ result: 'success', arguments: {} });
});
it('should handle RPC errors', async () => {
nock('http://localhost:9091')
.post('/transmission/rpc', { method: 'invalid-method', arguments: {} })
.reply(200, { result: 'error', 'error-message': 'Invalid request' });
await expect(client.makeRequest('invalid-method')).rejects.toThrow('Transmission RPC error: error');
});
});
describe('Download Normalization', () => {
it('should normalize torrent data correctly', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test Torrent',
status: 4, // downloading
totalSize: 1000000000,
sizeWhenDone: 1000000000,
leftUntilDone: 250000000,
rateDownload: 1048576,
rateUpload: 0,
eta: 3600,
downloadedEver: 750000000,
uploadedEver: 0,
percentDone: 0.75,
addedDate: 1640995200,
doneDate: 0,
labels: ['movies', 'hd'],
downloadDir: '/downloads/test'
};
const normalized = client.normalizeDownload(torrent);
expect(normalized).toEqual({
id: 'abc123',
title: 'Test Torrent',
type: 'torrent',
client: 'transmission',
instanceId: 'test-transmission',
instanceName: 'Test Transmission',
status: 'Downloading',
progress: 75,
size: 1000000000,
downloaded: 750000000,
speed: 1048576,
eta: 3600,
category: 'movies',
tags: ['movies', 'hd'],
savePath: '/downloads/test',
addedOn: '2022-01-01T00:00:00.000Z',
raw: torrent
});
});
it('should handle different torrent statuses', () => {
const statusMap = {
0: 'Stopped',
1: 'Queued',
2: 'Checking',
3: 'Queued',
4: 'Downloading',
5: 'Queued',
6: 'Seeding',
7: 'Unknown'
};
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: parseInt(status),
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.status).toBe(expectedStatus);
});
});
it('should handle unknown ETA values', () => {
const testCases = [
{ eta: -1, expected: null },
{ eta: -2, expected: null },
{ eta: 3600, expected: 3600 }
];
testCases.forEach(({ eta, expected }) => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: eta,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.eta).toBe(expected);
});
});
it('should extract category from first label', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: ['movies', 'hd', '4k']
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBe('movies');
expect(normalized.tags).toEqual(['movies', 'hd', '4k']);
});
it('should handle empty labels', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Test',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.category).toBeUndefined();
expect(normalized.tags).toEqual([]);
});
});
describe('Active Downloads', () => {
it('should fetch and normalize torrents', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: {
torrents: [
{
id: 1,
hashString: 'abc123',
name: 'Test Torrent 1',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 1048576,
eta: 3600,
percentDone: 0.5,
labels: ['test']
},
{
id: 2,
hashString: 'def456',
name: 'Test Torrent 2',
status: 6,
totalSize: 2000000,
sizeWhenDone: 2000000,
leftUntilDone: 0,
rateDownload: 0,
eta: -1,
percentDone: 1.0,
labels: []
}
]
}
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(client.makeRequest).toHaveBeenCalledWith('torrent-get', {
fields: [
'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone',
'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver',
'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats',
'labels', 'downloadDir', 'error', 'errorString', 'peersConnected',
'peersGettingFromUs', 'peersSendingToUs', 'queuePosition'
]
});
expect(downloads).toHaveLength(2);
expect(downloads[0].title).toBe('Test Torrent 1');
expect(downloads[0].status).toBe('Downloading');
expect(downloads[1].title).toBe('Test Torrent 2');
expect(downloads[1].status).toBe('Seeding');
});
it('should handle API errors gracefully', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('API Error'));
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
it('should handle empty torrent list', async () => {
const mockResponse = {
data: {
result: 'success',
arguments: { torrents: [] }
}
};
client.makeRequest = vi.fn().mockResolvedValue(mockResponse);
const downloads = await client.getActiveDownloads();
expect(downloads).toEqual([]);
});
});
describe('Client Status', () => {
it('should get client status and session stats', async () => {
const mockSessionResponse = {
data: {
result: 'success',
arguments: {
'download-dir': '/downloads',
'peer-port': 51413,
'rpc-version': 15
}
}
};
const mockStatsResponse = {
data: {
result: 'success',
arguments: {
'downloaded-bytes': 1000000000,
'uploaded-bytes': 500000000,
'torrent-count': 5
}
}
};
client.makeRequest = vi.fn()
.mockResolvedValueOnce(mockSessionResponse)
.mockResolvedValueOnce(mockStatsResponse);
const status = await client.getClientStatus();
expect(client.makeRequest).toHaveBeenCalledWith('session-get');
expect(client.makeRequest).toHaveBeenCalledWith('session-stats');
expect(status).toEqual({
session: mockSessionResponse.data.arguments,
stats: mockStatsResponse.data.arguments
});
});
it('should handle status request errors', async () => {
client.makeRequest = vi.fn().mockRejectedValue(new Error('Status error'));
const status = await client.getClientStatus();
expect(status).toBeNull();
});
});
describe('ARR Info Extraction', () => {
it('should extract series info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Show Name - S01E02 - Episode Title',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('series');
});
it('should extract movie info from filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Movie Title (2023) 1080p',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBe('movie');
});
it('should not extract ARR info from generic filename', () => {
const torrent = {
id: 1,
hashString: 'abc123',
name: 'Generic File Name.mkv',
status: 4,
totalSize: 1000000,
sizeWhenDone: 1000000,
leftUntilDone: 500000,
rateDownload: 0,
eta: -1,
percentDone: 0.5,
labels: []
};
const normalized = client.normalizeDownload(torrent);
expect(normalized.arrType).toBeUndefined();
});
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/config.js
*
+347
View File
@@ -0,0 +1,347 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import {
DownloadClientRegistry,
registry,
initializeClients,
getAllClients,
getClient,
getClientsByType,
getAllDownloads,
getDownloadsByClientType,
testAllConnections,
getAllClientStatuses
} from '../../server/utils/downloadClients.js';
import * as mockConfig from '../../server/utils/config.js';
import { vi } from 'vitest';
// Mock config and clients
vi.mock('../../server/utils/config', () => ({
getSABnzbdInstances: vi.fn(),
getQbittorrentInstances: vi.fn(),
getTransmissionInstances: vi.fn(),
getRtorrentInstances: vi.fn()
}));
vi.mock('../../server/utils/logger', () => ({
logToFile: vi.fn()
}));
vi.mock('../../server/clients/SABnzbdClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'sabnzbd',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/QBittorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'qbittorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' }),
resetFallbackFlag: vi.fn()
}));
});
vi.mock('../../server/clients/TransmissionClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'transmission',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'trans1', title: 'Trans Download 1', client: 'transmission' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
vi.mock('../../server/clients/RTorrentClient', () => {
return vi.fn().mockImplementation((config) => ({
getClientType: () => 'rtorrent',
getInstanceId: () => config.id,
name: config.name,
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'rt1', title: 'rTorrent Download 1', client: 'rtorrent' }
]),
testConnection: vi.fn().mockResolvedValue(true),
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
}));
});
describe('DownloadClientRegistry', () => {
let testRegistry;
beforeEach(() => {
testRegistry = new DownloadClientRegistry();
vi.clearAllMocks();
});
describe('Initialization', () => {
it('should initialize all configured client types', async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1'
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1'
};
const mockTransClient = {
getClientType: () => 'transmission',
getInstanceId: () => 'trans1',
name: 'Trans 1'
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
testRegistry.clients.set('trans1', mockTransClient);
expect(testRegistry.getAllClients()).toHaveLength(3);
expect(testRegistry.getClient('sab1')).toBeTruthy();
expect(testRegistry.getClient('qb1')).toBeTruthy();
expect(testRegistry.getClient('trans1')).toBeTruthy();
});
it('should handle empty config', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
it('should not initialize twice', async () => {
// Manually set initialized flag to true
testRegistry.initialized = true;
// Try to initialize again
await testRegistry.initialize();
// Config should not be called since initialized is true
expect(mockConfig.getSABnzbdInstances).not.toHaveBeenCalled();
});
it('should handle client creation errors gracefully', async () => {
// Registry is already empty from beforeEach
expect(testRegistry.getAllClients()).toHaveLength(0);
});
});
describe('Client Management', () => {
beforeEach(async () => {
// Manually add mock client to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
});
it('should get all clients', () => {
const clients = testRegistry.getAllClients();
expect(clients).toHaveLength(1);
expect(clients[0].getClientType()).toBe('sabnzbd');
});
it('should get client by ID', () => {
const client = testRegistry.getClient('sab1');
expect(client).toBeTruthy();
expect(client.getInstanceId()).toBe('sab1');
});
it('should return null for non-existent client', () => {
const client = testRegistry.getClient('nonexistent');
expect(client).toBeNull();
});
it('should get clients by type', () => {
const sabClients = testRegistry.getClientsByType('sabnzbd');
expect(sabClients).toHaveLength(1);
const qbClients = testRegistry.getClientsByType('qbittorrent');
expect(qbClients).toHaveLength(0);
});
});
describe('Download Management', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'sab1', title: 'SAB Download 1', client: 'sabnzbd' }
])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([
{ id: 'qb1', title: 'QB Download 1', client: 'qbittorrent' }
]),
resetFallbackFlag: vi.fn()
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should get all downloads from all clients', async () => {
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(2);
expect(downloads[0].client).toBe('sabnzbd');
expect(downloads[1].client).toBe('qbittorrent');
});
it('should reset fallback flags for qBittorrent clients', async () => {
const qbClient = testRegistry.getClient('qb1');
await testRegistry.getAllDownloads();
expect(qbClient.resetFallbackFlag).toHaveBeenCalled();
});
it('should get downloads grouped by client type', async () => {
const downloadsByType = await testRegistry.getDownloadsByClientType();
expect(downloadsByType.sabnzbd).toHaveLength(1);
expect(downloadsByType.qbittorrent).toHaveLength(1);
expect(downloadsByType.transmission).toBeUndefined();
});
it('should handle client errors gracefully', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getActiveDownloads.mockRejectedValue(new Error('Client error'));
const downloads = await testRegistry.getAllDownloads();
expect(downloads).toHaveLength(1); // Only qBittorrent succeeds
});
});
describe('Connection Testing', () => {
beforeEach(async () => {
// Manually add mock clients to the registry
const mockSabClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
const mockQbClient = {
getClientType: () => 'qbittorrent',
getInstanceId: () => 'qb1',
name: 'QB 1',
testConnection: vi.fn().mockResolvedValue(true),
getActiveDownloads: vi.fn().mockResolvedValue([])
};
testRegistry.clients.set('sab1', mockSabClient);
testRegistry.clients.set('qb1', mockQbClient);
});
it('should test all connections', async () => {
const results = await testRegistry.testAllConnections();
expect(results).toHaveLength(2);
expect(results[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
success: true,
error: null
});
expect(results[1]).toEqual({
instanceId: 'qb1',
instanceName: 'QB 1',
clientType: 'qbittorrent',
success: true,
error: null
});
});
it('should handle connection test failures', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.testConnection.mockRejectedValue(new Error('Connection failed'));
const results = await testRegistry.testAllConnections();
expect(results[0].success).toBe(false);
expect(results[0].error).toBe('Connection failed');
});
});
describe('Client Status', () => {
beforeEach(async () => {
// Manually add a mock client to the registry
const mockClient = {
getClientType: () => 'sabnzbd',
getInstanceId: () => 'sab1',
name: 'SAB 1',
getClientStatus: vi.fn().mockResolvedValue({ status: 'active' })
};
testRegistry.clients.set('sab1', mockClient);
});
it('should get all client statuses', async () => {
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0]).toEqual({
instanceId: 'sab1',
instanceName: 'SAB 1',
clientType: 'sabnzbd',
status: { status: 'active' }
});
});
it('should handle status request errors', async () => {
const sabClient = testRegistry.getClient('sab1');
sabClient.getClientStatus.mockRejectedValue(new Error('Status error'));
const statuses = await testRegistry.getAllClientStatuses();
expect(statuses[0].status).toBeNull();
expect(statuses[0].error).toBe('Status error');
});
});
});
describe('Convenience Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should delegate to singleton registry', async () => {
mockConfig.getSABnzbdInstances.mockReturnValue([]);
mockConfig.getQbittorrentInstances.mockReturnValue([]);
mockConfig.getTransmissionInstances.mockReturnValue([]);
await initializeClients();
expect(getAllClients()).toBeInstanceOf(Array);
expect(getClient('test')).toBeNull();
expect(getClientsByType('sabnzbd')).toBeInstanceOf(Array);
expect(await getAllDownloads()).toBeInstanceOf(Array);
expect(await getDownloadsByClientType()).toBeInstanceOf(Object);
expect(await testAllConnections()).toBeInstanceOf(Array);
expect(await getAllClientStatuses()).toBeInstanceOf(Array);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Unit tests for server/utils/historyFetcher.js
*
+259
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/qbittorrent.js pure utility functions.
*
@@ -7,6 +8,8 @@
*/
import { mapTorrentToDownload, formatBytes, formatSpeed, formatEta } from '../../server/utils/qbittorrent.js';
import QBittorrentClient from '../../server/clients/QBittorrentClient.js';
import nock from 'nock';
// Minimal torrent fixture that satisfies mapTorrentToDownload's expectations
function makeTorrent(overrides = {}) {
@@ -31,6 +34,35 @@ function makeTorrent(overrides = {}) {
};
}
const QBT_URL = 'http://qbittorrent.test:8080';
function makeClient(overrides = {}) {
return new QBittorrentClient({
id: 'test-qbt',
name: 'TestQBT',
url: QBT_URL,
username: 'admin',
password: 'adminadmin',
...overrides
});
}
function mockLogin() {
return nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=abc123; path=/'] });
}
function mockSync(rid, response) {
return nock(QBT_URL)
.get(`/api/v2/sync/maindata?rid=${rid}`)
.reply(200, response);
}
afterEach(() => {
nock.cleanAll();
});
describe('formatBytes', () => {
it('formats 0 bytes', () => expect(formatBytes(0)).toBe('0 B'));
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
@@ -109,3 +141,230 @@ describe('mapTorrentToDownload', () => {
expect(result.savePath).toBe('/dl/');
});
});
describe('QBittorrentClient sync API', () => {
it('first call uses rid=0 and returns full torrent list', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test1');
expect(torrents[0].instanceId).toBe('test-qbt');
expect(torrents[0].id).toBe('hash01');
expect(client.lastRid).toBe(1);
});
it('subsequent call uses last rid and merges delta', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Second call — delta
mockSync(1, {
rid: 2,
full_update: false,
torrents: {
hash01: { dlspeed: 200 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].speed).toBe(200);
expect(torrents[0].title).toBe('Test1');
expect(client.lastRid).toBe(2);
});
it('handles full_update=true on subsequent call', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First call
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
// Server forces full refresh
mockSync(1, {
rid: 2,
full_update: true,
torrents: {
hash02: { name: 'Test2', state: 'uploading', size: 2000, progress: 1.0, dlspeed: 0, eta: 0, num_seeds: 10, num_leechs: 0, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Test2');
expect(torrents[0].id).toBe('hash02');
expect(client.lastRid).toBe(2);
});
it('removes torrents when torrents_removed is present', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'Test1', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
await client.getActiveDownloads();
mockSync(1, {
rid: 2,
full_update: false,
torrents_removed: ['hash01']
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(0);
});
it('falls back to torrents/info when sync fails', async () => {
mockLogin();
const client = makeClient();
await client.login();
// Sync fails with 500
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(500, { error: 'Internal Server Error' });
// Legacy succeeds
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'Fallback', hash: 'fb01', state: 'downloading', size: 1073741824, progress: 0.5, dlspeed: 1048576, eta: 512, num_seeds: 10, num_leechs: 3, availability: 1.0 }
]);
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('Fallback');
expect(client.fallbackThisCycle).toBe(true);
});
it('uses legacy immediately if already fell back this cycle', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// Only legacy should be called
nock(QBT_URL)
.get('/api/v2/torrents/info')
.reply(200, [
{ name: 'DirectLegacy', hash: 'dl01', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
]);
// Ensure sync is NOT called
const syncScope = nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, { rid: 1, full_update: true });
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('DirectLegacy');
expect(syncScope.isDone()).toBe(false);
});
it('re-authenticates on 403 during sync and retries', async () => {
mockLogin();
const client = makeClient();
await client.login();
// First sync call returns 403
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(403, {});
// Re-login
nock(QBT_URL)
.post('/api/v2/auth/login')
.reply(200, {}, { 'set-cookie': ['SID=newtoken; path=/'] });
// Retry succeeds
nock(QBT_URL)
.get('/api/v2/sync/maindata?rid=0')
.reply(200, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'AfterReauth', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents).toHaveLength(1);
expect(torrents[0].title).toBe('AfterReauth');
});
it('computes completed from size and progress when missing', async () => {
mockLogin();
const client = makeClient();
await client.login();
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'NoCompleted', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
const torrents = await client.getActiveDownloads();
expect(torrents[0].downloaded).toBe(500);
});
it('resets fallback flag when getAllTorrents resets it', async () => {
mockLogin();
const client = makeClient();
await client.login();
client.fallbackThisCycle = true;
// After reset, sync should be attempted
mockSync(0, {
rid: 1,
full_update: true,
torrents: {
hash01: { name: 'ResetWorks', state: 'downloading', size: 1000, progress: 0.5, dlspeed: 100, eta: 60, num_seeds: 5, num_leechs: 2, availability: 1.0 }
}
});
// Simulate the reset that getAllTorrents performs
client.fallbackThisCycle = false;
const torrents = await client.getActiveDownloads();
expect(torrents[0].title).toBe('ResetWorks');
expect(client.fallbackThisCycle).toBe(false);
});
});
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/middleware/requireAuth.js
*
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/sanitizeError.js
*
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/utils/tokenStore.js
*
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Tests for server/middleware/verifyCsrf.js
*
+1
View File
@@ -1,3 +1,4 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { defineConfig } from 'vitest/config';
export default defineConfig({