Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 879aee8eea | |||
| 70710061b8 | |||
| f8f693e32a | |||
| 501a4c83bb | |||
| 6fa9c79a7d | |||
| 3d49c926dc | |||
| bd7a9c7951 | |||
| 4a5dc70548 | |||
| 498eabc7bc | |||
| 6b73727d4e | |||
| 593ad79670 | |||
| c18f5bd26e | |||
| b4a9d7187b | |||
| 691d101e56 | |||
| e726fbe33f | |||
| 6f2901b08c | |||
| 4107bdf611 | |||
| a4af16064b | |||
| 52806d00dc | |||
| d6907f42d3 | |||
| aec04474be | |||
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 | |||
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 | |||
| 0eaa54cf4a | |||
| 865cf1f57a | |||
| ff5f50cc3a | |||
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 |
@@ -169,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
OMBI_URL=https://ombi.example.com
|
OMBI_URL=https://ombi.example.com
|
||||||
OMBI_API_KEY=your-ombi-api-key-here
|
OMBI_API_KEY=your-ombi-api-key-here
|
||||||
|
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
|
||||||
|
# to resolve the race condition where Ombi fires the webhook before committing to its database.
|
||||||
|
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOTES
|
# NOTES
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'release/**'
|
- 'release/**'
|
||||||
- 'develop*'
|
- 'develop*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -23,20 +27,14 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building develop image tags: ${TAGS}"
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
# Primary registry tags
|
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
|
||||||
|
|
||||||
# Gitea package registry tags
|
# Gitea package registry tags
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
audit:
|
audit:
|
||||||
|
|||||||
@@ -4,6 +4,98 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.32] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TransmissionClient Hardening (Issue #63)** — Mapped the previously-unknown Transmission RPC status code `7` to `Checking` (best-effort; the RPC spec formally documents only codes 0–6, and the historical alias `TORRENT_IS_CHECKING` corresponds to code 2), so torrents reporting code 7 are now rendered with a useful status label instead of `Unknown`. Implemented three torrent control methods on `TransmissionClient` that were previously absent: `startTorrent(id)` (resumes via `torrent-start`), `stopTorrent(id)` (pauses via `torrent-stop`), and `removeTorrent(id, deleteData = false)` (removes via `torrent-remove`, optionally also deleting local files via Transmission's `delete-local-data` flag). All three accept either a single id (numeric or hash) or an array of ids, matching the Transmission RPC contract. Documented in `extractArrInfo()` that an `arrQueueId` cannot reliably be derived from filename alone — the cross-client matching path is hash-based via `DownloadMatcher.matchTorrents()` (Issue #65), which keys on `torrent.hashString` for Transmission. Added regression tests for status code 7 and all three control methods. Resolves Gitea Issue [#63](https://git.i3omb.com/Gandalf/sofarr/issues/63).
|
||||||
|
- **Frontend Build Stability (Issue #66)** — Added explanatory inline comments to `client/vite.config.js` documenting two non-standard but deliberate build settings: `build.outDir = '../public'` (the Vite bundle is emitted into the Express-served `public/` directory at the repo root rather than the Vite-default `client/dist/`) and `build.emptyOutDir = false` (required so the hand-authored static assets committed under `public/` are not wiped by every `vite build`). The comments explicitly warn that changing either setting without also updating the Express static-serve configuration in `server/app.js` and the Dockerfile copy steps will break production serving. Removed a stale, untracked `client/dist/` directory (a leftover from an earlier default-Vite build) that was harmless — both `.gitignore` and `.dockerignore` already excluded it from version control and Docker contexts — but caused recurring confusion about which `index.html` was authoritative. Verified `client/index.html` correctly references `/src/main.js` as the Vite entrypoint. Resolves Gitea Issue [#66](https://git.i3omb.com/Gandalf/sofarr/issues/66).
|
||||||
|
- **Download Matching & Deduplication (Issue #65)** — `DownloadMatcher.matchTorrents()` now attempts hash-first matching for every torrent before falling back to title-substring matching. The hash lookup compares `torrent.hash` (qBittorrent, rTorrent) or `torrent.hashString` (Transmission) against each *arr* queue/history record's `downloadId`, restoring deterministic matching for renamed downloads and torrents whose on-disk filename has diverged from the *arr* release title. Title-substring matching is retained verbatim as a fallback so unhashed clients and legacy fixtures continue to work. After the per-torrent matching pass, the returned list is deduplicated by the composite key `(arrType, arrQueueId)`: the first matched download wins, so a single torrent that maps to N *arr* queue records sharing one queue id (for example, a season pack exposed as multiple per-episode rows) produces a single dashboard card instead of N near-identical duplicates. A new integration suite at `tests/integration/download-matcher-season-pack.test.js` covers hash-first matching for qBittorrent (`hash`) and Transmission (`hashString`), the title-substring fallback path, and the deduplication step. Resolves Gitea Issue [#65](https://git.i3omb.com/Gandalf/sofarr/issues/65).
|
||||||
|
- **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64).
|
||||||
|
- **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: <n>` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61).
|
||||||
|
- **rTorrent Null-Safety, SABnzbd History Limit & Client Last-Error Visibility (Issue #68)** — Three related hardening improvements to the download-client layer. First, `RTorrentClient` now defends against the malformed-response scenarios observed against misconfigured or transiently-restarting rTorrent servers: `getActiveDownloads()` explicitly checks that `d.multicall2` returned an actual array (logging a warning and returning `[]` if not, rather than throwing on `.map`) and processes each torrent row in its own `try`/`catch` so a single malformed entry cannot poison the whole result set. All eleven field values retrieved from the multicall response are coerced to their expected types via explicit `Number()`/`String()` conversions in `normalizeDownload()`, so downstream arithmetic and string operations can no longer blow up on `null` or `undefined` values from plugins or older rTorrent versions. `_extractArrInfo()` now short-circuits safely on non-string filenames. `getClientStatus()` additionally coerces the global rate values through `Number.isFinite` before returning them. Second, the SABnzbd history limit (previously hard-coded to `10` records per poll) is now configurable via the `SAB_HISTORY_LIMIT` environment variable. Invalid or absent values fall back to the default of `10` with a log warning, ensuring backward compatibility. Third, all four download clients (`RTorrentClient`, `SABnzbdClient`, `QBittorrentClient`, `TransmissionClient`) now record structured `lastError` objects (`{ operation, message, at }`) on every failed API call via `_recordLastError()` and clear them on subsequent success via `_clearLastError()` — both helpers introduced on the `DownloadClient` base class alongside the public `getLastError()` accessor. The per-client last-error is surfaced through `DownloadClientRegistry.getAllClientStatuses()` and exposed on the `GET /api/status/status` admin endpoint under the new `downloadClients` array, letting the admin panel show a per-client failure indicator without log scraping. New regression tests cover all null-safety paths, the SAB history limit env variable (unset, valid, invalid, propagated to the API call), and the full lastError set/clear cycle for both rTorrent and SABnzbd. Resolves Gitea Issue [#68](https://git.i3omb.com/Gandalf/sofarr/issues/68).
|
||||||
|
- **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62).
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||||
|
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||||
|
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||||
|
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||||
|
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||||
|
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
|
||||||
|
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
|
||||||
|
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
|
||||||
|
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
|
||||||
|
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
|
||||||
|
|
||||||
|
## [1.7.23] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
|
||||||
|
|
||||||
|
## [1.7.22] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
|
||||||
|
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
|
||||||
|
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
|
||||||
|
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
|
||||||
|
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
|
||||||
|
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.21] - 2026-05-26
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copy application source owned by root (read-only at runtime)
|
# Copy application source owned by root (read-only at runtime)
|
||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
COPY --from=client-build --chown=root:root /app/public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+99
-19
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.user || request.User ||
|
||||||
return request.requestedUser.alias ||
|
request.requestedBy || request.RequestedBy ||
|
||||||
request.requestedUser.userAlias ||
|
request.ombiUser || request.OmbiUser ||
|
||||||
request.requestedUser.userName ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
request.requestedUser.normalizedUserName ||
|
|
||||||
request.requestedByAlias || '';
|
// If userSource is an object, extract key fields
|
||||||
|
if (userSource && typeof userSource === 'object') {
|
||||||
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
// If userSource is a string
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRequests() {
|
export function renderRequests() {
|
||||||
@@ -111,11 +146,39 @@ function createRequestCard(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const username = extractRequestedUser(request);
|
const username = extractRequestedUser(request);
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = document.createElement('span');
|
|
||||||
user.className = 'request-user';
|
|
||||||
user.textContent = `Requested by: ${username}`;
|
user.textContent = `Requested by: ${username}`;
|
||||||
meta.appendChild(user);
|
} else {
|
||||||
|
user.textContent = 'Requested by: Unknown (Ombi)';
|
||||||
|
user.title = 'No user information received from Ombi';
|
||||||
|
user.style.cursor = 'help';
|
||||||
|
user.style.textDecoration = 'underline dotted';
|
||||||
|
}
|
||||||
|
meta.appendChild(user);
|
||||||
|
|
||||||
|
const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
|
||||||
|
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
|
||||||
|
if (dateStr) {
|
||||||
|
const requestDate = document.createElement('span');
|
||||||
|
requestDate.className = 'request-date';
|
||||||
|
try {
|
||||||
|
const dateObj = new Date(dateStr);
|
||||||
|
if (!isNaN(dateObj.getTime())) {
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(dateObj.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
|
||||||
|
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
meta.appendChild(requestDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.quality) {
|
if (request.quality) {
|
||||||
@@ -128,25 +191,42 @@ function createRequestCard(request) {
|
|||||||
content.appendChild(title);
|
content.appendChild(title);
|
||||||
content.appendChild(meta);
|
content.appendChild(meta);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'request-actions';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'request-link ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
|
||||||
ombiLink.target = '_blank';
|
ombiLink.target = '_blank';
|
||||||
ombiLink.title = 'View in Ombi';
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
const ombiIcon = document.createElement('img');
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.className = 'service-icon ombi';
|
||||||
ombiIcon.src = '/images/ombi.svg';
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
ombiIcon.alt = 'Ombi';
|
ombiIcon.alt = 'Ombi';
|
||||||
ombiIcon.className = 'request-icon';
|
|
||||||
|
|
||||||
ombiLink.appendChild(ombiIcon);
|
ombiLink.appendChild(ombiIcon);
|
||||||
actions.appendChild(ombiLink);
|
actions.appendChild(ombiLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && request.arrLink) {
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.className = `${request.arrType}-link`;
|
||||||
|
arrLink.href = request.arrLink;
|
||||||
|
arrLink.target = '_blank';
|
||||||
|
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
|
||||||
|
|
||||||
|
const arrIcon = document.createElement('img');
|
||||||
|
arrIcon.className = `service-icon ${request.arrType}`;
|
||||||
|
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||||
|
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
|
||||||
|
arrLink.appendChild(arrIcon);
|
||||||
|
actions.appendChild(arrLink);
|
||||||
|
}
|
||||||
|
|
||||||
card.appendChild(typeIcon);
|
card.appendChild(typeIcon);
|
||||||
card.appendChild(content);
|
card.appendChild(content);
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|||||||
+29
-10
@@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js';
|
|||||||
|
|
||||||
// Apply saved theme immediately on load
|
// Apply saved theme immediately on load
|
||||||
(function applyTheme() {
|
(function applyTheme() {
|
||||||
const theme = getTheme();
|
const theme = getTheme() || 'light';
|
||||||
if (theme) {
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function initThemeSwitcher() {
|
export function initThemeSwitcher() {
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
if (!themeToggle) return;
|
const currentTheme = getTheme() || 'light';
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
// Set initial active state on buttons
|
||||||
const currentTheme = getTheme();
|
themeButtons.forEach(btn => {
|
||||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||||
setTheme(newTheme);
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const theme = btn.getAttribute('data-theme');
|
||||||
|
if (theme) {
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme) {
|
export function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
saveTheme(theme);
|
saveTheme(theme);
|
||||||
|
|
||||||
|
// Sync button active classes if elements are present on the page
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
themeButtons.forEach(btn => {
|
||||||
|
if (btn.getAttribute('data-theme') === theme) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-24
@@ -1,28 +1,50 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
build: {
|
// Load env variables from root directory to match backend TLS configuration
|
||||||
outDir: '../public',
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
emptyOutDir: false,
|
|
||||||
rollupOptions: {
|
const port = env.PORT || 3001;
|
||||||
input: {
|
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
main: './src/main.js'
|
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||||
},
|
|
||||||
output: {
|
return {
|
||||||
entryFileNames: 'app.js',
|
build: {
|
||||||
chunkFileNames: '[name].js',
|
// NOTE (Issue #66): `outDir` is intentionally the repo-root `../public`,
|
||||||
assetFileNames: '[name][extname]'
|
// NOT the Vite default `client/dist/`. The Express server in
|
||||||
|
// `server/app.js` serves static assets directly from `public/`, so the
|
||||||
|
// Vite build emits its bundle alongside the hand-authored static assets
|
||||||
|
// (favicon, etc.) that live in `public/` and are committed to the repo.
|
||||||
|
// Do NOT change this back to `dist/` without also updating the Express
|
||||||
|
// static-serve configuration and the Dockerfile copy steps.
|
||||||
|
outDir: '../public',
|
||||||
|
// NOTE (Issue #66): `emptyOutDir: false` is REQUIRED because `public/`
|
||||||
|
// contains hand-authored static assets that must survive the build.
|
||||||
|
// Setting this to `true` would wipe those assets on every `vite build`.
|
||||||
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: './src/main.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'app.js',
|
||||||
|
chunkFileNames: '[name].js',
|
||||||
|
assetFileNames: '[name][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true, // Listen on all network interfaces
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: target,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false // Allow self-signed certificate in development
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
server: {
|
});
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.21",
|
"version": "1.7.32",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.21",
|
"version": "1.7.32",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.21",
|
"version": "1.7.32",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
|
"build": "npm run build --prefix client",
|
||||||
"install:all": "npm install",
|
"install:all": "npm install",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
+22
-20
File diff suppressed because one or more lines are too long
+40
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.21"
|
* example: "1.7.32"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
@@ -232,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||||
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||||
|
app.use(express.static(PUBLIC_DIR, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -25,6 +25,41 @@ class DownloadClient {
|
|||||||
this.apiKey = instanceConfig.apiKey;
|
this.apiKey = instanceConfig.apiKey;
|
||||||
this.username = instanceConfig.username;
|
this.username = instanceConfig.username;
|
||||||
this.password = instanceConfig.password;
|
this.password = instanceConfig.password;
|
||||||
|
|
||||||
|
// Last error encountered while talking to this client.
|
||||||
|
// Cleared on successful calls via _clearLastError(); set via _recordLastError().
|
||||||
|
// Surfaced through getAllClientStatuses() so the admin status panel can show
|
||||||
|
// a per-client failure indicator without needing to scrape logs.
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an error encountered while talking to this client.
|
||||||
|
* @param {string} operation - Short description of the operation (e.g. 'getActiveDownloads')
|
||||||
|
* @param {Error|string} error - Error object or message
|
||||||
|
*/
|
||||||
|
_recordLastError(operation, error) {
|
||||||
|
const message = (error && error.message) ? error.message : String(error || 'unknown error');
|
||||||
|
this.lastError = {
|
||||||
|
operation,
|
||||||
|
message,
|
||||||
|
at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the last error (called when an operation succeeds).
|
||||||
|
*/
|
||||||
|
_clearLastError() {
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public accessor for the last recorded error, or null if none.
|
||||||
|
* @returns {{operation:string, message:string, at:string}|null}
|
||||||
|
*/
|
||||||
|
getLastError() {
|
||||||
|
return this.lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
_hydrateRequest(req) {
|
_hydrateRequest(req) {
|
||||||
if (!req) return req;
|
if (!req) return req;
|
||||||
|
|
||||||
|
let result = req;
|
||||||
|
|
||||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||||
@@ -179,14 +181,55 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
...req,
|
...req,
|
||||||
requestedUser: hydratedUser,
|
requestedUser: hydratedUser,
|
||||||
RequestedUser: hydratedUser
|
RequestedUser: hydratedUser
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return req;
|
|
||||||
|
// Hydrate childRequests (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const hydratedChildren = result.childRequests.map(child => {
|
||||||
|
if (!child) return child;
|
||||||
|
|
||||||
|
const childUserId = child.requestedUserId || child.RequestedUserId;
|
||||||
|
if (childUserId && this.cache.userMap.has(childUserId)) {
|
||||||
|
const cachedUser = this.cache.userMap.get(childUserId);
|
||||||
|
let childUser = child.requestedUser || child.RequestedUser;
|
||||||
|
|
||||||
|
if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) {
|
||||||
|
const hydratedUser = {
|
||||||
|
id: cachedUser.id,
|
||||||
|
userName: cachedUser.userName,
|
||||||
|
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||||
|
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||||
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
result = { ...result, childRequests: hydratedChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote requestedDate from childRequests to top level (common for Ombi TV)
|
||||||
|
if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate;
|
||||||
|
if (childDate) {
|
||||||
|
result = { ...result, requestedDate: childDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
// Try a simple API call to verify connection
|
// Try a simple API call to verify connection
|
||||||
await this.makeRequest('/api/v2/app/version');
|
await this.makeRequest('/api/v2/app/version');
|
||||||
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
|
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`);
|
||||||
|
return Array.from(this.torrentMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
if (data.full_update) {
|
if (data.full_update) {
|
||||||
// Full refresh: rebuild the entire map
|
// Full refresh: rebuild the entire map
|
||||||
this.torrentMap.clear();
|
this.torrentMap.clear();
|
||||||
@@ -169,6 +176,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
|
|
||||||
const torrents = await this.getMainData();
|
const torrents = await this.getMainData();
|
||||||
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
|
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
|
||||||
|
this._clearLastError();
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
|
||||||
@@ -183,6 +191,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', fallbackError);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +202,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest('/api/v2/sync/maindata');
|
const response = await this.makeRequest('/api/v2/sync/maindata');
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
serverState: data.server_state || {},
|
serverState: data.server_state || {},
|
||||||
rid: data.rid,
|
rid: data.rid,
|
||||||
@@ -200,6 +210,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +260,14 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
downloaded: downloadedSize,
|
downloaded: downloadedSize,
|
||||||
speed: torrent.dlspeed,
|
speed: torrent.dlspeed,
|
||||||
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
||||||
|
// Connected peer counts (Issue #64). qBittorrent exposes:
|
||||||
|
// num_seeds — connected seeds (peers we have a connection to)
|
||||||
|
// num_leechs — connected leechers (peers downloading from us)
|
||||||
|
// num_complete / num_incomplete — *swarm* totals reported by tracker
|
||||||
|
// We expose the connected counts to stay consistent with what other
|
||||||
|
// clients (e.g. Transmission via peersConnected/peersSendingToUs) report.
|
||||||
|
seeds: torrent.num_seeds ?? 0,
|
||||||
|
peers: torrent.num_leechs ?? 0,
|
||||||
category: torrent.category || undefined,
|
category: torrent.category || undefined,
|
||||||
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
||||||
savePath: torrent.content_path || torrent.save_path || undefined,
|
savePath: torrent.content_path || torrent.save_path || undefined,
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ class RTorrentClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
await this._methodCall('system.client_version');
|
await this._methodCall('system.client_version');
|
||||||
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,10 +79,31 @@ class RTorrentClient extends DownloadClient {
|
|||||||
'd.custom1='
|
'd.custom1='
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// rTorrent XML-RPC can occasionally return null/undefined or a non-array
|
||||||
|
// on misconfigured servers or transient errors. Guard against that here
|
||||||
|
// so callers always get a sane array instead of throwing on .map.
|
||||||
|
if (!Array.isArray(torrents)) {
|
||||||
|
logToFile(`[rtorrent:${this.name}] d.multicall2 returned non-array (${typeof torrents}); treating as empty`);
|
||||||
|
this._clearLastError();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
this._clearLastError();
|
||||||
|
// Filter out any individual rows that fail to normalize so a single bad
|
||||||
|
// record cannot poison the whole result set.
|
||||||
|
const normalized = [];
|
||||||
|
for (const torrent of torrents) {
|
||||||
|
try {
|
||||||
|
normalized.push(this.normalizeDownload(torrent));
|
||||||
|
} catch (err) {
|
||||||
|
logToFile(`[rtorrent:${this.name}] Skipping malformed torrent row: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,31 +115,53 @@ class RTorrentClient extends DownloadClient {
|
|||||||
this._methodCall('throttle.global_up.rate')
|
this._methodCall('throttle.global_up.rate')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
globalDownRate: downRate,
|
globalDownRate: Number.isFinite(downRate) ? downRate : 0,
|
||||||
globalUpRate: upRate
|
globalUpRate: Number.isFinite(upRate) ? upRate : 0
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeDownload(torrent) {
|
normalizeDownload(torrent) {
|
||||||
|
// rTorrent's d.multicall2 returns an array of fields in the order requested.
|
||||||
|
// If a value is missing rtorrent typically returns '' or 0, but plugins and
|
||||||
|
// older versions can return undefined/null — coerce everything explicitly so
|
||||||
|
// downstream math and string ops never blow up on null/undefined.
|
||||||
|
if (!Array.isArray(torrent)) {
|
||||||
|
throw new Error('Expected torrent row to be an array');
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
hash,
|
hashRaw,
|
||||||
name,
|
nameRaw,
|
||||||
sizeBytes,
|
sizeBytesRaw,
|
||||||
completedBytes,
|
completedBytesRaw,
|
||||||
downRate,
|
downRateRaw,
|
||||||
upRate,
|
upRateRaw,
|
||||||
state,
|
stateRaw,
|
||||||
isActive,
|
isActiveRaw,
|
||||||
isHashChecking,
|
isHashCheckingRaw,
|
||||||
directory,
|
directoryRaw,
|
||||||
custom1
|
custom1Raw
|
||||||
] = torrent;
|
] = torrent;
|
||||||
|
|
||||||
|
const hash = hashRaw ? String(hashRaw) : '';
|
||||||
|
const name = nameRaw ? String(nameRaw) : '';
|
||||||
|
const sizeBytes = Number(sizeBytesRaw) || 0;
|
||||||
|
const completedBytes = Number(completedBytesRaw) || 0;
|
||||||
|
const downRate = Number(downRateRaw) || 0;
|
||||||
|
const upRate = Number(upRateRaw) || 0;
|
||||||
|
const state = Number.isFinite(Number(stateRaw)) ? Number(stateRaw) : 0;
|
||||||
|
const isActive = Number.isFinite(Number(isActiveRaw)) ? Number(isActiveRaw) : 0;
|
||||||
|
const isHashChecking = Number.isFinite(Number(isHashCheckingRaw)) ? Number(isHashCheckingRaw) : 0;
|
||||||
|
const directory = directoryRaw ? String(directoryRaw) : '';
|
||||||
|
const custom1 = custom1Raw ? String(custom1Raw) : '';
|
||||||
|
|
||||||
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
||||||
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
||||||
|
|
||||||
@@ -168,6 +213,11 @@ class RTorrentClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_extractArrInfo(filename) {
|
_extractArrInfo(filename) {
|
||||||
|
// Null-safe: getActiveDownloads passes a normalized string, but guard anyway
|
||||||
|
// so callers passing raw rtorrent values cannot crash this helper.
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||||
if (seriesMatch) {
|
if (seriesMatch) {
|
||||||
return { type: 'series' };
|
return { type: 'series' };
|
||||||
|
|||||||
@@ -3,9 +3,27 @@ const axios = require('axios');
|
|||||||
const DownloadClient = require('./DownloadClient');
|
const DownloadClient = require('./DownloadClient');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
|
|
||||||
|
// Number of recently completed jobs to pull from SABnzbd's /api?mode=history on
|
||||||
|
// every poll. Larger values let DownloadMatcher correlate slightly older jobs
|
||||||
|
// with their Sonarr/Radarr queue entries at the cost of one wider HTTP
|
||||||
|
// response per poll cycle. Configurable via the SAB_HISTORY_LIMIT environment
|
||||||
|
// variable; defaults to 10 to match the previous hardcoded value.
|
||||||
|
const DEFAULT_HISTORY_LIMIT = 10;
|
||||||
|
function resolveHistoryLimit() {
|
||||||
|
const raw = process.env.SAB_HISTORY_LIMIT;
|
||||||
|
if (raw === undefined || raw === null || raw === '') return DEFAULT_HISTORY_LIMIT;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
logToFile(`[SABnzbd] Invalid SAB_HISTORY_LIMIT='${raw}'; falling back to ${DEFAULT_HISTORY_LIMIT}`);
|
||||||
|
return DEFAULT_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
class SABnzbdClient extends DownloadClient {
|
class SABnzbdClient extends DownloadClient {
|
||||||
constructor(instance) {
|
constructor(instance) {
|
||||||
super(instance);
|
super(instance);
|
||||||
|
this.historyLimit = resolveHistoryLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientType() {
|
getClientType() {
|
||||||
@@ -16,9 +34,11 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
const response = await this.makeRequest('', { mode: 'version' });
|
const response = await this.makeRequest('', { mode: 'version' });
|
||||||
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
|
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +67,7 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
// Get both queue and history to provide complete picture
|
// Get both queue and history to provide complete picture
|
||||||
const [queueResponse, historyResponse] = await Promise.all([
|
const [queueResponse, historyResponse] = await Promise.all([
|
||||||
this.makeRequest({ mode: 'queue' }),
|
this.makeRequest({ mode: 'queue' }),
|
||||||
this.makeRequest({ mode: 'history', limit: 10 })
|
this.makeRequest({ mode: 'history', limit: this.historyLimit })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const queueData = queueResponse.data;
|
const queueData = queueResponse.data;
|
||||||
@@ -99,10 +119,12 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
|
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads (historyLimit=${this.historyLimit})`);
|
||||||
|
this._clearLastError();
|
||||||
return downloads;
|
return downloads;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,8 +134,12 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest({ mode: 'queue' });
|
const response = await this.makeRequest({ mode: 'queue' });
|
||||||
const queueData = response.data.queue;
|
const queueData = response.data.queue;
|
||||||
|
|
||||||
if (!queueData) return null;
|
if (!queueData) {
|
||||||
|
this._clearLastError();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
status: queueData.status,
|
status: queueData.status,
|
||||||
speed: queueData.speed,
|
speed: queueData.speed,
|
||||||
@@ -128,6 +154,7 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
await this.makeRequest('session-get');
|
await this.makeRequest('session-get');
|
||||||
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,10 +82,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
|
|
||||||
const torrents = response.data.arguments.torrents || [];
|
const torrents = response.data.arguments.torrents || [];
|
||||||
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||||
|
this._clearLastError();
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,12 +96,14 @@ class TransmissionClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest('session-get');
|
const response = await this.makeRequest('session-get');
|
||||||
const sessionStats = await this.makeRequest('session-stats');
|
const sessionStats = await this.makeRequest('session-stats');
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
session: response.data.arguments,
|
session: response.data.arguments,
|
||||||
stats: sessionStats.data.arguments
|
stats: sessionStats.data.arguments
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +118,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
4: 'Downloading', // TORRENT_DOWNLOAD
|
4: 'Downloading', // TORRENT_DOWNLOAD
|
||||||
5: 'Queued', // TORRENT_SEED_WAIT
|
5: 'Queued', // TORRENT_SEED_WAIT
|
||||||
6: 'Seeding', // TORRENT_SEED
|
6: 'Seeding', // TORRENT_SEED
|
||||||
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
|
// Status code 7 is undocumented in the Transmission RPC spec (which
|
||||||
|
// formally defines only 0–6). The legacy alias "TORRENT_IS_CHECKING"
|
||||||
|
// (a duplicate of code 2) is the best-effort interpretation; map it to
|
||||||
|
// `Checking` so it is rendered usefully rather than as `Unknown`.
|
||||||
|
7: 'Checking' // TORRENT_IS_CHECKING (undocumented; treated as code 2)
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = statusMap[torrent.status] || 'Unknown';
|
const status = statusMap[torrent.status] || 'Unknown';
|
||||||
@@ -160,7 +169,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extractArrInfo(filename) {
|
extractArrInfo(filename) {
|
||||||
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
|
// arrQueueId cannot be extracted from filename alone; *arr exposes that
|
||||||
|
// identifier only via its queue API. The reliable cross-client matching
|
||||||
|
// path is hash-based and lives in `DownloadMatcher.matchTorrents()` (see
|
||||||
|
// Issue #65), which keys on `torrent.hashString` for Transmission.
|
||||||
|
// This heuristic remains only to provide a coarse `type` hint.
|
||||||
|
|
||||||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||||
@@ -176,6 +189,39 @@ class TransmissionClient extends DownloadClient {
|
|||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (resume) one or more torrents. `id` is the Transmission internal
|
||||||
|
* numeric id or a hashString; the RPC accepts either.
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
*/
|
||||||
|
async startTorrent(id) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-start', { ids });
|
||||||
|
logToFile(`[Transmission:${this.name}] Started torrent(s): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop (pause) one or more torrents.
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
*/
|
||||||
|
async stopTorrent(id) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-stop', { ids });
|
||||||
|
logToFile(`[Transmission:${this.name}] Stopped torrent(s): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one or more torrents. When `deleteData` is true the local files
|
||||||
|
* are also deleted from disk (Transmission's `delete-local-data`).
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
* @param {boolean} [deleteData=false]
|
||||||
|
*/
|
||||||
|
async removeTorrent(id, deleteData = false) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-remove', { ids, 'delete-local-data': !!deleteData });
|
||||||
|
logToFile(`[Transmission:${this.name}] Removed torrent(s): ${ids.join(', ')} (deleteData=${!!deleteData})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TransmissionClient;
|
module.exports = TransmissionClient;
|
||||||
|
|||||||
+2
-287
@@ -82,20 +82,9 @@ console.error = function(...args) {
|
|||||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
|
||||||
const radarrRoutes = require('./routes/radarr');
|
|
||||||
const embyRoutes = require('./routes/emby');
|
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
|
||||||
const statusRoutes = require('./routes/status');
|
|
||||||
const historyRoutes = require('./routes/history');
|
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
const webhookRoutes = require('./routes/webhook');
|
|
||||||
const ombiRoutes = require('./routes/ombi');
|
|
||||||
const debugRoutes = require('./routes/debug');
|
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Startup environment validation
|
// Startup environment validation
|
||||||
@@ -117,284 +106,10 @@ if (process.env.EMBY_URL) {
|
|||||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Load OpenAPI spec from YAML
|
|
||||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
||||||
|
|
||||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
||||||
const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
...openapiSpec,
|
|
||||||
openapi: '3.1.0'
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, 'routes/*.js'),
|
|
||||||
path.join(__dirname, 'index.js')
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
||||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
||||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (process.env.TRUST_PROXY) {
|
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
|
||||||
: process.env.TRUST_PROXY;
|
|
||||||
app.set('trust proxy', trustValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helmet v7 — security response headers
|
|
||||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
||||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// Generate a fresh nonce for every request
|
|
||||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
formAction: ["'self'"],
|
|
||||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000, // 1 year
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true
|
|
||||||
},
|
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// General API rate limiter — applies to all /api/* routes
|
|
||||||
// More specific limiters (e.g. login) apply on top of this.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
|
||||||
message: { error: 'Too many requests, please try again later' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body parsing & cookies
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use(cookieParser(cookieSecret || undefined));
|
|
||||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /health:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Health check
|
|
||||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is healthy
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ok"
|
|
||||||
* uptime:
|
|
||||||
* type: number
|
|
||||||
* description: Server uptime in seconds
|
|
||||||
* example: 3600.5
|
|
||||||
* version:
|
|
||||||
* type: string
|
|
||||||
* description: sofarr version
|
|
||||||
* example: "1.7.21"
|
|
||||||
*/
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /ready:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Readiness check
|
|
||||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ready"
|
|
||||||
* '503':
|
|
||||||
* description: Server not ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "not ready"
|
|
||||||
* reason:
|
|
||||||
* type: string
|
|
||||||
* example: "EMBY_URL not configured"
|
|
||||||
*/
|
|
||||||
app.get('/ready', (req, res) => {
|
|
||||||
// Confirm critical config is present
|
|
||||||
const ready = !!(process.env.EMBY_URL);
|
|
||||||
if (ready) {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
} else {
|
|
||||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Swagger UI - publicly accessible API documentation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
||||||
customSiteTitle: 'sofarr API Documentation',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
customJs: [
|
|
||||||
'/swagger-auth-banner.js'
|
|
||||||
],
|
|
||||||
swaggerOptions: {
|
|
||||||
url: '/api/swagger.json'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
||||||
app.get('/api/swagger.json', (req, res) => {
|
|
||||||
// Clone the spec to avoid modifying the original
|
|
||||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
||||||
|
|
||||||
// Replace the server URL with the current request's origin
|
|
||||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(specCopy);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static files — served before API routes
|
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html.
|
|
||||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
||||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
||||||
app.use(express.static(PUBLIC_DIR, {
|
|
||||||
index: false,
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve index.html with CSP nonce injected into <script> tags
|
|
||||||
function serveIndex(req, res) {
|
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
|
||||||
const nonce = res.locals.cspNonce;
|
|
||||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
||||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
||||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
||||||
// the old nonce which no longer matches the per-request CSP header).
|
|
||||||
const patched = html
|
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.send(patched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
||||||
// CSRF protection applies to all state-changing /api/* requests except
|
|
||||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api', apiLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/webhook', webhookRoutes);
|
|
||||||
app.use('/api/debug', debugRoutes);
|
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
|
||||||
app.use('/api', verifyCsrf);
|
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
|
||||||
app.use('/api/radarr', radarrRoutes);
|
|
||||||
app.use('/api/emby', embyRoutes);
|
|
||||||
app.use('/api/ombi', ombiRoutes);
|
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
|
||||||
app.use('/api/status', statusRoutes);
|
|
||||||
app.use('/api/history', historyRoutes);
|
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
|
||||||
app.get('*', serveIndex);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global error handler — never leak stack traces to clients
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('[Server] Unhandled error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TLS / HTTPS support
|
// TLS / HTTPS support
|
||||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.21
|
version: 1.7.32
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
|||||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||||
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
||||||
id: c.getInstanceId(),
|
id: c.getInstanceId(),
|
||||||
@@ -525,8 +533,14 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||||
|
|
||||||
|
|
||||||
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
|
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
|
||||||
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
|
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
|
||||||
|
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
const ombiRequestsFiltered = {
|
const ombiRequestsFiltered = {
|
||||||
movie: filteredOmbiMovieRequests,
|
movie: filteredOmbiMovieRequests,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { logToFile } = require('../utils/logger');
|
|||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -126,6 +126,11 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateRequestsWithArrLinks(allRequests, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
let types = req.query.type;
|
let types = req.query.type;
|
||||||
let statuses = req.query.status;
|
let statuses = req.query.status;
|
||||||
|
|||||||
+11
-1
@@ -7,6 +7,7 @@ const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
|||||||
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||||
|
const downloadClientRegistry = require('../utils/downloadClients');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -165,7 +166,16 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||||
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
||||||
}
|
},
|
||||||
|
// Per-download-client health summary including any lastError captured
|
||||||
|
// since the last successful call. Lets the admin status panel surface
|
||||||
|
// transient failures (auth expiry, RPC blips, etc.) without log scraping.
|
||||||
|
downloadClients: downloadClientRegistry.getAllClients().map(c => ({
|
||||||
|
instanceId: c.getInstanceId(),
|
||||||
|
instanceName: c.name,
|
||||||
|
clientType: c.getClientType(),
|
||||||
|
lastError: typeof c.getLastError === 'function' ? c.getLastError() : null
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
||||||
|
|||||||
+92
-35
@@ -5,6 +5,7 @@ const { logToFile } = require('../utils/logger');
|
|||||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
|
const { buildArrQueueCache } = require('../utils/arrQueueHelpers');
|
||||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
@@ -106,9 +107,14 @@ function pruneReplayCache() {
|
|||||||
// Prune the replay cache once per minute
|
// Prune the replay cache once per minute
|
||||||
setInterval(pruneReplayCache, 60 * 1000).unref();
|
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||||
|
|
||||||
function isReplay(eventType, instanceName, eventDate) {
|
function isReplay(eventType, instanceName, eventDate, contentId) {
|
||||||
if (!eventDate) return false;
|
if (!eventDate) return false;
|
||||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
// Content-aware replay key: incorporates downloadId / series.id / movie.id when
|
||||||
|
// available so that distinct events sharing the same `date` (e.g. multiple
|
||||||
|
// Grab events for episodes in a season pack fired in the same second) do not
|
||||||
|
// falsely collide. Falls back to the prior shape when contentId is absent
|
||||||
|
// (e.g. Test events) so existing behaviour is preserved.
|
||||||
|
const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`;
|
||||||
if (recentEvents.has(key)) return true;
|
if (recentEvents.has(key)) return true;
|
||||||
recentEvents.set(key, Date.now());
|
recentEvents.set(key, Date.now());
|
||||||
return false;
|
return false;
|
||||||
@@ -180,7 +186,7 @@ function validateWebhookSecret(req) {
|
|||||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
@@ -202,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const sonarrQueues = queuesByType.sonarr || [];
|
const sonarrQueues = queuesByType.sonarr || [];
|
||||||
cache.set('poll:sonarr-queue', {
|
cache.set('poll:sonarr-queue', {
|
||||||
records: sonarrQueues.flatMap(q => {
|
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.series) r.series._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -232,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const radarrQueues = queuesByType.radarr || [];
|
const radarrQueues = queuesByType.radarr || [];
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -259,9 +245,66 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const ombiInstances = getOmbiInstances();
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
if (affectsOmbi) {
|
if (affectsOmbi) {
|
||||||
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
||||||
|
await new Promise(r => setTimeout(r, initialDelay));
|
||||||
|
|
||||||
|
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
||||||
|
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
||||||
|
|
||||||
|
let ombiRequests = { movie: [], tv: [] };
|
||||||
|
let foundAndValid = false;
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelayMs = 1500;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 1) {
|
||||||
|
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in movie or tv lists
|
||||||
|
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
||||||
|
// Also check both if mediaType not specified
|
||||||
|
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
const user = extractRequestedUser(targetReq);
|
||||||
|
if (user) {
|
||||||
|
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAndValid && requestId) {
|
||||||
|
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
||||||
|
// Try to log the raw target request if we found one
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||||
}
|
}
|
||||||
@@ -423,11 +466,18 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
|
const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||||
|
const inst = matchedInst || sonarrInstances[0];
|
||||||
|
if (!matchedInst && instanceName) {
|
||||||
|
logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||||
|
}
|
||||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||||
|
|
||||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
// Content-aware replay key components (Issue #62)
|
||||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
const contentId = req.body.downloadId || req.body.series?.id || null;
|
||||||
|
|
||||||
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,11 +627,18 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
|
const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||||
|
const inst = matchedInst || radarrInstances[0];
|
||||||
|
if (!matchedInst && instanceName) {
|
||||||
|
logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||||
|
}
|
||||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||||
|
|
||||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
// Content-aware replay key components (Issue #62)
|
||||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
const contentId = req.body.downloadId || req.body.movie?.id || null;
|
||||||
|
|
||||||
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +834,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background cache refresh + SSE broadcast (fire-and-forget)
|
// Background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
processWebhookEvent('ombi', eventType).catch(err => {
|
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -429,7 +429,20 @@ async function matchTorrents(torrents, context) {
|
|||||||
|
|
||||||
let matchedAny = false;
|
let matchedAny = false;
|
||||||
|
|
||||||
const sonarrMatch = sonarrQueueRecords.find(r => {
|
// Hash-first matching (Issue #65): prefer matching by torrent hash against
|
||||||
|
// each *arr queue record's `downloadId`. `torrent.hash` covers qBittorrent
|
||||||
|
// and rTorrent; `torrent.hashString` covers Transmission. We fall back to
|
||||||
|
// existing title-substring matching only if no hash match was found.
|
||||||
|
const torrentHash = torrent?.hash || torrent?.hashString || null;
|
||||||
|
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
|
||||||
|
const matchesByHash = (r) => {
|
||||||
|
const dl = r && r.downloadId;
|
||||||
|
if (!dl || !hashLower) return false;
|
||||||
|
return String(dl).toLowerCase() === hashLower;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
|
||||||
|
if (!sonarrMatch) sonarrMatch = sonarrQueueRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||||
});
|
});
|
||||||
@@ -480,7 +493,8 @@ async function matchTorrents(torrents, context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMatch = radarrQueueRecords.find(r => {
|
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
|
||||||
|
if (!radarrMatch) radarrMatch = radarrQueueRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||||
});
|
});
|
||||||
@@ -529,7 +543,8 @@ async function matchTorrents(torrents, context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
|
||||||
|
if (!sonarrHistoryMatch) sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||||
});
|
});
|
||||||
@@ -562,7 +577,8 @@ async function matchTorrents(torrents, context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
|
||||||
|
if (!radarrHistoryMatch) radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||||
});
|
});
|
||||||
@@ -598,7 +614,24 @@ async function matchTorrents(torrents, context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
return matched;
|
|
||||||
|
// Deduplicate by (arrType, arrQueueId) (Issue #65). When a single torrent
|
||||||
|
// (typically a season pack) matches N *arr queue records sharing one
|
||||||
|
// arrQueueId via downstream emission paths, only the first matched download
|
||||||
|
// is retained. Entries without an arrQueueId pass through unchanged.
|
||||||
|
const seen = new Set();
|
||||||
|
const deduped = [];
|
||||||
|
for (const m of matched) {
|
||||||
|
const key = (m && m.arrType && m.arrQueueId != null)
|
||||||
|
? `${m.arrType}:${m.arrQueueId}`
|
||||||
|
: null;
|
||||||
|
if (key) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
deduped.push(m);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
//
|
||||||
|
// Shared helpers for assembling the cached *arr queue payload.
|
||||||
|
//
|
||||||
|
// Both the background poller (`server/utils/poller.js`) and the webhook
|
||||||
|
// processor (`server/routes/webhook.js`) build the `poll:sonarr-queue` and
|
||||||
|
// `poll:radarr-queue` cache entries from an array of per-instance queue
|
||||||
|
// responses. Historically the same `flatMap` block was duplicated across all
|
||||||
|
// four call sites (Sonarr + Radarr × poller + webhook) and had begun to drift.
|
||||||
|
//
|
||||||
|
// This module centralises that logic, adds defensive null-guards, and — for
|
||||||
|
// Sonarr only — annotates season-pack records (queue entries sharing a
|
||||||
|
// `downloadId`) with `isSeasonPack` and `episodeCount`. See Issue #61.
|
||||||
|
//
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the flattened, instance-tagged `records` array for the
|
||||||
|
* `poll:sonarr-queue` / `poll:radarr-queue` cache entry.
|
||||||
|
*
|
||||||
|
* @param {Array<{ instance: string, data: { records?: Array<object> } }>} queues
|
||||||
|
* Per-instance queue responses as returned by
|
||||||
|
* `arrRetrieverRegistry.getQueuesByType()` (or the equivalent batched
|
||||||
|
* retrieval in the poller).
|
||||||
|
* @param {Array<{ id: string, url: string, apiKey: string, name?: string }>} instances
|
||||||
|
* Configured instances; used to resolve `_instanceUrl` / `_instanceKey`.
|
||||||
|
* @param {'series'|'movie'} mediaKey
|
||||||
|
* Sonarr records embed a `series` object; Radarr records embed a `movie`
|
||||||
|
* object. The embedded object is annotated with `_instanceUrl` so that
|
||||||
|
* downstream link builders work.
|
||||||
|
* @returns {Array<object>} The flattened, annotated records array.
|
||||||
|
*/
|
||||||
|
function buildArrQueueCache(queues, instances, mediaKey) {
|
||||||
|
if (!Array.isArray(queues) || queues.length === 0) return [];
|
||||||
|
if (mediaKey !== 'series' && mediaKey !== 'movie') {
|
||||||
|
logToFile(`[arrQueueHelpers] Invalid mediaKey "${mediaKey}"; expected 'series' or 'movie'`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const safeInstances = Array.isArray(instances) ? instances : [];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const q of queues) {
|
||||||
|
try {
|
||||||
|
if (!q || !q.data) continue;
|
||||||
|
const inst = safeInstances.find(i => i.id === q.instance);
|
||||||
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
|
const records = Array.isArray(q.data.records) ? q.data.records : [];
|
||||||
|
for (const r of records) {
|
||||||
|
try {
|
||||||
|
if (!r) continue;
|
||||||
|
if (r[mediaKey]) {
|
||||||
|
r[mediaKey]._instanceUrl = url;
|
||||||
|
}
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
|
out.push(r);
|
||||||
|
} catch (perRecordErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} record: ${perRecordErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (perInstanceErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} queue payload: ${perInstanceErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sonarr-only: season pack annotation. Group by downloadId; entries that
|
||||||
|
// share a downloadId are episodes belonging to the same release (a season
|
||||||
|
// pack). Movies (mediaKey === 'movie') are single-record by nature.
|
||||||
|
if (mediaKey === 'series') {
|
||||||
|
try {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const r of out) {
|
||||||
|
const dlId = r && r.downloadId;
|
||||||
|
if (!dlId) continue;
|
||||||
|
if (!groups.has(dlId)) groups.set(dlId, []);
|
||||||
|
groups.get(dlId).push(r);
|
||||||
|
}
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.length > 1) {
|
||||||
|
for (const r of group) {
|
||||||
|
r.isSeasonPack = true;
|
||||||
|
r.episodeCount = group.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (annotateErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Season-pack annotation failed: ${annotateErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildArrQueueCache
|
||||||
|
};
|
||||||
@@ -239,7 +239,10 @@ class DownloadClientRegistry {
|
|||||||
instanceId: client.getInstanceId(),
|
instanceId: client.getInstanceId(),
|
||||||
instanceName: client.name,
|
instanceName: client.name,
|
||||||
clientType: client.getClientType(),
|
clientType: client.getClientType(),
|
||||||
status
|
status,
|
||||||
|
// Surface the per-client lastError so admins can see transient
|
||||||
|
// failures (auth expiry, RPC blips, etc.) without scraping logs.
|
||||||
|
lastError: typeof client.getLastError === 'function' ? client.getLastError() : null
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
|
logToFile(`[DownloadClientRegistry] Error getting status from ${client.name}: ${error.message}`);
|
||||||
@@ -248,7 +251,8 @@ class DownloadClientRegistry {
|
|||||||
instanceName: client.name,
|
instanceName: client.name,
|
||||||
clientType: client.getClientType(),
|
clientType: client.getClientType(),
|
||||||
status: null,
|
status: null,
|
||||||
error: error.message
|
error: error.message,
|
||||||
|
lastError: typeof client.getLastError === 'function' ? client.getLastError() : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+205
-12
@@ -5,6 +5,8 @@
|
|||||||
* not a string, so we need to extract the username from the object.
|
* not a string, so we need to extract the username from the object.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the username from an Ombi request object.
|
* Extracts the username from an Ombi request object.
|
||||||
* Handles both the OmbiUser object format and legacy string format.
|
* Handles both the OmbiUser object format and legacy string format.
|
||||||
@@ -15,19 +17,57 @@
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
|
request.user || request.User ||
|
||||||
|
request.requestedBy || request.RequestedBy ||
|
||||||
|
request.ombiUser || request.OmbiUser ||
|
||||||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// If userSource is an object, extract key fields
|
||||||
if (requestedUser && typeof requestedUser === 'object') {
|
if (userSource && typeof userSource === 'object') {
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
const username = userSource.alias || userSource.Alias ||
|
||||||
return requestedUser.alias || requestedUser.Alias ||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
userSource.userName || userSource.UserName ||
|
||||||
requestedUser.userName || requestedUser.UserName ||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
request.requestedByAlias || request.RequestedByAlias || '';
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
// If userSource is a string and not an empty object/array
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warning log when user extraction returns empty for non-empty requests
|
||||||
|
if (Object.keys(request).length > 0 && !request.notificationType) {
|
||||||
|
logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRequestsByUser(requests, username, showAll) {
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
@@ -40,7 +80,160 @@ function filterRequestsByUser(requests, username, showAll) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(requests)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
requests.forEach(req => {
|
||||||
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
|
// Fallback to checking for TV specific IDs.
|
||||||
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
|
if (!tvdbId) return;
|
||||||
|
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
req.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
|
||||||
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
req.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(downloads)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
downloads.forEach(dl => {
|
||||||
|
// Determine if it's TV (series) or Movie
|
||||||
|
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// Look for a match in Sonarr instances
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => {
|
||||||
|
if (!s) return false;
|
||||||
|
// Match by database series ID if the instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to seriesName matching
|
||||||
|
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
|
||||||
|
// Look for a match in Radarr instances
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => {
|
||||||
|
if (!m) return false;
|
||||||
|
// Match by database movie ID if instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to movieName matching
|
||||||
|
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extractRequestedUser,
|
extractRequestedUser,
|
||||||
filterRequestsByUser
|
filterRequestsByUser,
|
||||||
|
decorateRequestsWithArrLinks,
|
||||||
|
decorateDownloadsWithArrLinks
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-22
@@ -3,6 +3,7 @@ const axios = require('axios');
|
|||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
const { buildArrQueueCache } = require('./arrQueueHelpers');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
@@ -237,17 +238,7 @@ async function pollAllServices() {
|
|||||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||||
cache.set('poll:sonarr-queue', {
|
cache.set('poll:sonarr-queue', {
|
||||||
records: sonarrQueues.flatMap(q => {
|
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.series) r.series._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, cacheTTL);
|
}, cacheTTL);
|
||||||
cache.set('poll:sonarr-history', {
|
cache.set('poll:sonarr-history', {
|
||||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||||
@@ -265,17 +256,7 @@ async function pollAllServices() {
|
|||||||
// Radarr
|
// Radarr
|
||||||
if (shouldPollRadarr) {
|
if (shouldPollRadarr) {
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, cacheTTL);
|
}, cacheTTL);
|
||||||
cache.set('poll:radarr-history', {
|
cache.set('poll:radarr-history', {
|
||||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||||
|
|||||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
|||||||
expect(result.childNodes.length).toBe(0);
|
expect(result.childNodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('createDownloadCard rendering details', () => {
|
||||||
|
let originalState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalState = { ...state };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset global state
|
||||||
|
Object.assign(state, originalState);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createClientLogo and fallbacks', () => {
|
||||||
|
it('renders client logo img tag when client is configured', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceName: 'Qbit Main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
|
||||||
|
const img = wrapper.querySelector('img.download-client-logo');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||||
|
expect(img.alt).toBe('Qbit Main icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to character avatar text on img load error', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'transmission'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
|
||||||
|
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||||
|
img.onerror();
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||||
|
expect(wrapper.textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createServiceIcons deep-linking', () => {
|
||||||
|
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||||
|
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
ombiLink: 'https://ombi.test/request/42',
|
||||||
|
ombiTooltip: 'View on Ombi'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const ombiLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(ombiLinkEl).toBeTruthy();
|
||||||
|
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||||
|
|
||||||
|
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('View on Ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Sonarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Radarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Blade Runner 2049',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrType: 'radarr',
|
||||||
|
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-movie a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||||
|
state.isAdmin = false; // Non-admin
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/requests.js
|
||||||
|
*
|
||||||
|
* Verifies requests dashboard rendering, tooltips, dates, and deep links.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/state.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
ombiRequests: { movie: [], tv: [] },
|
||||||
|
selectedRequestTypes: ['movie', 'tv'],
|
||||||
|
selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'],
|
||||||
|
requestSortMode: 'requestedDate_desc',
|
||||||
|
requestSearchQuery: '',
|
||||||
|
ombiBaseUrl: 'https://ombi.test',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requests rendering', () => {
|
||||||
|
let requestsList, noRequests;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="requests-list"></div>
|
||||||
|
<div id="no-requests" style="display: none;"><p></p></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
requestsList = document.getElementById('requests-list');
|
||||||
|
noRequests = document.getElementById('no-requests');
|
||||||
|
|
||||||
|
state.ombiRequests = { movie: [], tv: [] };
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiBaseUrl = 'https://ombi.test';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No requests found." when request arrays are empty', () => {
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(0);
|
||||||
|
expect(noRequests.style.display).toBe('block');
|
||||||
|
expect(noRequests.querySelector('p').textContent).toBe('No requests found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders request card with correctly formatted date, media type, and requester', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
year: '2026',
|
||||||
|
requestedUser: { alias: 'john_doe' },
|
||||||
|
requestedDate: '2026-05-27T10:15:30.000Z',
|
||||||
|
quality: '1080p',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.request-title').textContent).toBe('Movie Test');
|
||||||
|
expect(card.querySelector('.request-year').textContent).toBe('2026');
|
||||||
|
expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe');
|
||||||
|
|
||||||
|
// Check formatted date
|
||||||
|
const dateEl = card.querySelector('.request-date');
|
||||||
|
expect(dateEl).toBeTruthy();
|
||||||
|
expect(dateEl.textContent).toContain('Date: 2026-05-27');
|
||||||
|
|
||||||
|
// Check view in Ombi link
|
||||||
|
const ombiLink = card.querySelector('.ombi-link');
|
||||||
|
expect(ombiLink).toBeTruthy();
|
||||||
|
expect(ombiLink.href).toBe('https://ombi.test/details/movie/555');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
title: 'TV Test No User',
|
||||||
|
requestedDate: '2026-05-27T12:00:00.000Z',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
const userEl = card.querySelector('.request-user');
|
||||||
|
expect(userEl).toBeTruthy();
|
||||||
|
expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)');
|
||||||
|
expect(userEl.title).toBe('No user information received from Ombi');
|
||||||
|
expect(userEl.style.textDecoration).toBe('underline dotted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render Sonarr/Radarr deep links for non-admin users', () => {
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.radarr-link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => {
|
||||||
|
state.isAdmin = true;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
title: 'TV Show Test',
|
||||||
|
theMovieDbId: 666,
|
||||||
|
arrLink: 'http://sonarr:8989/series/slug',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(2);
|
||||||
|
|
||||||
|
// Check Radarr link
|
||||||
|
const movieCard = requestsList.childNodes[0];
|
||||||
|
const radarrLink = movieCard.querySelector('.radarr-link');
|
||||||
|
expect(radarrLink).toBeTruthy();
|
||||||
|
expect(radarrLink.href).toBe('http://radarr:7878/movie/slug');
|
||||||
|
expect(radarrLink.title).toBe('View in Radarr');
|
||||||
|
|
||||||
|
// Check Sonarr link
|
||||||
|
const tvCard = requestsList.childNodes[1];
|
||||||
|
const sonarrLink = tvCard.querySelector('.sonarr-link');
|
||||||
|
expect(sonarrLink).toBeTruthy();
|
||||||
|
expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug');
|
||||||
|
expect(sonarrLink.title).toBe('View in Sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/theme.js
|
||||||
|
*
|
||||||
|
* Verifies DOM actions for theme switcher button clicks, attributes, and storage calls.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js';
|
||||||
|
import * as storage from '../../../client/src/utils/storage.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/utils/storage.js', () => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getTheme: vi.fn(() => store.theme || 'light'),
|
||||||
|
saveTheme: vi.fn((theme) => { store.theme = theme; })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('theme switcher', () => {
|
||||||
|
let lightBtn, darkBtn, monoBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
|
||||||
|
// Create mock theme buttons
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
lightBtn = document.querySelector('[data-theme="light"]');
|
||||||
|
darkBtn = document.querySelector('[data-theme="dark"]');
|
||||||
|
monoBtn = document.querySelector('[data-theme="mono"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher sets active class based on saved theme on load', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue('dark');
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(storage.getTheme).toHaveBeenCalled();
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher defaults to light theme if no theme is saved', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue(null);
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking theme button switches the document theme and persists choice', () => {
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
// Initial active button should be light
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
// Click Dark
|
||||||
|
darkBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('dark');
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
|
||||||
|
// Click Mono
|
||||||
|
monoBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTheme directly sets document attribute and updates button classes if present', () => {
|
||||||
|
initThemeSwitcher(); // binds buttons
|
||||||
|
|
||||||
|
setTheme('mono');
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.canBlocklist).toBe(true);
|
expect(dl.canBlocklist).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
|
||||||
|
it('decorates active series downloads with Sonarr links for administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
// Seed cache: queue record exists and matches SABnzbd slot
|
||||||
|
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr /api/v3/movie response
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const downloads = res.body.downloads;
|
||||||
|
const dl = downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
|
||||||
|
expect(dl.arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1093,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
|||||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||||
|
const { cookies } = await loginAs(appInstance);
|
||||||
|
const res = await request(appInstance)
|
||||||
|
.get('/api/dashboard/stream')
|
||||||
|
.query({ testClose: 'true' })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('data:');
|
||||||
|
|
||||||
|
const dataStr = text.substring(text.indexOf('{'));
|
||||||
|
const data = JSON.parse(dataStr.trim());
|
||||||
|
|
||||||
|
// Payload Contract Validation
|
||||||
|
expect(data).toHaveProperty('user');
|
||||||
|
expect(data).toHaveProperty('isAdmin');
|
||||||
|
expect(data).toHaveProperty('downloads');
|
||||||
|
expect(data).toHaveProperty('downloadClients');
|
||||||
|
expect(data).toHaveProperty('ombiRequests');
|
||||||
|
expect(data).toHaveProperty('ombiBaseUrl');
|
||||||
|
|
||||||
|
expect(Array.isArray(data.downloads)).toBe(true);
|
||||||
|
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 1. Get the route handler from the dashboard router stack
|
||||||
|
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||||
|
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||||
|
// Get the final handler (after requireAuth middleware)
|
||||||
|
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
// 2. Setup mock req and res
|
||||||
|
const mockUser = { name: 'Alice', isAdmin: false };
|
||||||
|
const reqOnCallbacks = {};
|
||||||
|
const mockReq = {
|
||||||
|
user: mockUser,
|
||||||
|
query: { showAll: 'false', testClose: 'false' },
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
reqOnCallbacks[event] = cb;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const resWrites = [];
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
flushHeaders: vi.fn(),
|
||||||
|
write: vi.fn((data) => {
|
||||||
|
resWrites.push(data);
|
||||||
|
}),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Call the handler
|
||||||
|
await streamHandler(mockReq, mockRes);
|
||||||
|
|
||||||
|
// Initial payload should be written
|
||||||
|
expect(resWrites.length).toBeGreaterThan(0);
|
||||||
|
expect(resWrites[0]).toContain('data:');
|
||||||
|
|
||||||
|
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
|
||||||
|
// Check that heartbeat was written
|
||||||
|
expect(resWrites).toContain(': heartbeat\n\n');
|
||||||
|
|
||||||
|
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||||
|
expect(reqOnCallbacks['close']).toBeDefined();
|
||||||
|
reqOnCallbacks['close']();
|
||||||
|
|
||||||
|
// Check that advancing time again does NOT write another heartbeat
|
||||||
|
const beforeLength = resWrites.length;
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
//
|
||||||
|
// Integration tests for the torrent matcher's hash-first matching and
|
||||||
|
// arrQueueId deduplication paths (Issue #65). These exercise `matchTorrents`
|
||||||
|
// end-to-end against minimal but realistic queue/history record fixtures.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const DownloadMatcher = require('../../server/services/DownloadMatcher');
|
||||||
|
|
||||||
|
// Build a minimal context. `showAll: true` bypasses per-user tag filtering so
|
||||||
|
// these tests can assert matching behaviour without setting up the Emby user
|
||||||
|
// tag plumbing.
|
||||||
|
function makeContext({
|
||||||
|
sonarrQueueRecords = [],
|
||||||
|
sonarrHistoryRecords = [],
|
||||||
|
radarrQueueRecords = [],
|
||||||
|
radarrHistoryRecords = []
|
||||||
|
} = {}) {
|
||||||
|
const seriesMap = new Map();
|
||||||
|
const moviesMap = new Map();
|
||||||
|
for (const r of sonarrQueueRecords.concat(sonarrHistoryRecords)) {
|
||||||
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
|
for (const r of radarrQueueRecords.concat(radarrHistoryRecords)) {
|
||||||
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sonarrQueueRecords,
|
||||||
|
sonarrHistoryRecords,
|
||||||
|
radarrQueueRecords,
|
||||||
|
radarrHistoryRecords,
|
||||||
|
seriesMap,
|
||||||
|
moviesMap,
|
||||||
|
// null tagMaps so extractAllTags uses the object `label` shape from the
|
||||||
|
// Sonarr fixture (series.tags = [{ label: '...' }]). An empty Map is
|
||||||
|
// truthy and would cause every id-lookup to return undefined.
|
||||||
|
sonarrTagMap: null,
|
||||||
|
radarrTagMap: null,
|
||||||
|
username: 'tester',
|
||||||
|
isAdmin: false,
|
||||||
|
// showAll bypasses per-user tag filtering — we only need it to be truthy
|
||||||
|
// *together* with non-empty allTags. We seed series/movie tags as non-empty
|
||||||
|
// strings (Sonarr tag shape) so `extractAllTags` yields entries.
|
||||||
|
showAll: true,
|
||||||
|
embyUserMap: new Map(),
|
||||||
|
ombiBaseUrl: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesShowA = {
|
||||||
|
id: 100,
|
||||||
|
title: 'Show A',
|
||||||
|
tags: [{ label: 'tester' }],
|
||||||
|
images: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const movieFilmB = {
|
||||||
|
id: 200,
|
||||||
|
title: 'Film B',
|
||||||
|
tags: [{ label: 'tester' }],
|
||||||
|
images: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('matchTorrents — hash-first matching (#65)', () => {
|
||||||
|
it('matches a torrent to a Sonarr queue record by hash even when the title differs', async () => {
|
||||||
|
const hash = 'ABC123HASHsonarr';
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 9001,
|
||||||
|
// Title intentionally bears no resemblance to the torrent name to
|
||||||
|
// prove the match is via hash (downloadId), not title fallback.
|
||||||
|
title: 'totally.unrelated.queue.record',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: hash,
|
||||||
|
episodeId: 555
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
hash,
|
||||||
|
name: 'Show.A.S01.2160p.WEB.x265-RELEASE',
|
||||||
|
progress: 0.5,
|
||||||
|
dlspeed: 1000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('sonarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(9001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a Transmission torrent via hashString', async () => {
|
||||||
|
const hashString = 'TRANSMISSIONHASH456';
|
||||||
|
const context = makeContext({
|
||||||
|
radarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 7777,
|
||||||
|
title: 'unrelated',
|
||||||
|
sourceTitle: '',
|
||||||
|
movieId: 200,
|
||||||
|
movie: movieFilmB,
|
||||||
|
downloadId: hashString
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
hashString,
|
||||||
|
name: 'Film.B.2160p.WEB.x265-RELEASE',
|
||||||
|
progress: 0.25,
|
||||||
|
dlspeed: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('radarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(7777);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to title-substring matching when no hash is present on the torrent', async () => {
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 555,
|
||||||
|
title: 'Show A',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
// No hash / hashString — title fallback must engage.
|
||||||
|
name: 'Show A — S01E02',
|
||||||
|
progress: 0.7,
|
||||||
|
dlspeed: 5000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('sonarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(555);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchTorrents — arrQueueId deduplication (#65)', () => {
|
||||||
|
it('deduplicates two torrents matching distinct queue records sharing one arrQueueId via the same hash', async () => {
|
||||||
|
// Construct the pathological case the dedup step is designed for: two
|
||||||
|
// torrents (post-hash-match) both end up mapped to the same arrQueueId.
|
||||||
|
// In real life this happens when *arr exposes multiple queue rows under
|
||||||
|
// one logical download. The first matched download wins; subsequent ones
|
||||||
|
// are dropped.
|
||||||
|
const hash = 'PACKHASH001';
|
||||||
|
const sharedQueueRow = {
|
||||||
|
id: 4242, // same arrQueueId
|
||||||
|
title: 'unrelated',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: hash
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [sharedQueueRow]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{ hash, name: 'Show.A.S02E01', progress: 0.1, dlspeed: 0 },
|
||||||
|
{ hash, name: 'Show.A.S02E02', progress: 0.2, dlspeed: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrQueueId).toBe(4242);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not deduplicate torrents that lack arrQueueId (no matched *arr record)', async () => {
|
||||||
|
const context = makeContext();
|
||||||
|
const torrents = [
|
||||||
|
{ hash: 'no-match-A', name: 'unmatched-A', progress: 0, dlspeed: 0 },
|
||||||
|
{ hash: 'no-match-B', name: 'unmatched-B', progress: 0, dlspeed: 0 }
|
||||||
|
];
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
// Both unmatched torrents are filtered out by the matcher entirely because
|
||||||
|
// there is nothing to match against — so the deduplicator never sees them.
|
||||||
|
// This test simply asserts the dedup step itself does not collapse
|
||||||
|
// non-arr entries into a single bucket when no key is present.
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||||
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||||
|
|
||||||
|
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||||
|
const RADARR_BASE = 'https://radarr-decor.test';
|
||||||
|
|
||||||
|
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
// Reset the singleton retrievers registry so we can inject our test instances
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// Configure test environment variables for retrievers
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||||
|
// Mock Sonarr series query
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr movie query (empty)
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrSeriesId: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||||
|
expect(downloads[0].arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||||
|
// Mock Sonarr series query (empty)
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
// Mock Radarr movie query with matching ID
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'Blade.Runner.2049.2017.1080p',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrInstanceUrl: RADARR_BASE,
|
||||||
|
arrContentId: 99
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||||
|
expect(downloads[0].arrType).toBe('radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips decoration entirely when isAdmin is false', async () => {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// No nocks are set up, so any HTTP calls would throw or error
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, false);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty downloads array gracefully', async () => {
|
||||||
|
// No mock setups needed, should complete without throwing
|
||||||
|
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||||
|
// Mock Sonarr series query throwing connection error
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
// Mock Radarr movie query throwing timeout error
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No links decorated since the fetch failed
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let originalSkipRateLimit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Save current rate limiting skip flag
|
||||||
|
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||||
|
// Explicitly delete it before loading the app so rate limiters are active
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
|
||||||
|
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||||
|
const appModule = await import('../../server/app.js');
|
||||||
|
const createApp = appModule.createApp;
|
||||||
|
|
||||||
|
// Create a new app instance with rate limiting enabled
|
||||||
|
app = createApp({ skipRateLimits: false });
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore rate limit skip flag
|
||||||
|
if (originalSkipRateLimit !== undefined) {
|
||||||
|
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||||
|
} else {
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||||
|
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||||
|
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||||
|
// count toward the rate limit window of 10 requests.
|
||||||
|
nock('https://emby.test')
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(401, { error: 'Unauthorized' })
|
||||||
|
.persist();
|
||||||
|
|
||||||
|
// Fire 10 rapid failed login requests (the limit is 10)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11th request must be rate limited and return 429
|
||||||
|
const limitRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(limitRes.status).toBe(429);
|
||||||
|
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -183,3 +183,152 @@ describe('arrRetrieverRegistry', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OmbiRetriever._hydrateRequest', () => {
|
||||||
|
let retriever;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
retriever = new OmbiRetriever({
|
||||||
|
id: 'ombi-test',
|
||||||
|
name: 'Test Ombi',
|
||||||
|
url: 'http://localhost:5000',
|
||||||
|
apiKey: 'test-key'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the userMap cache
|
||||||
|
retriever.cache.userMap.set('user-1', {
|
||||||
|
id: 'user-1',
|
||||||
|
userName: 'testuser',
|
||||||
|
alias: 'TestUser',
|
||||||
|
userAlias: 'TestUser',
|
||||||
|
normalizedUserName: 'testuser'
|
||||||
|
});
|
||||||
|
retriever.cache.userMap.set('user-2', {
|
||||||
|
id: 'user-2',
|
||||||
|
userName: 'adminuser',
|
||||||
|
alias: 'AdminUser',
|
||||||
|
userAlias: 'AdminUser',
|
||||||
|
normalizedUserName: 'adminuser'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates top-level requestedUserId', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {}
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.requestedUser.alias).toBe('TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates childRequests requestedUserId (TV requests)', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {},
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-2',
|
||||||
|
requestedUser: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('adminuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes requestedDate from childRequests to top level', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite existing top-level requestedDate', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
requestedDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles PascalCase RequestedDate from childRequests', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
RequestedDate: '2026-06-01T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unmodified request when no hydration needed', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Movie',
|
||||||
|
requestedUser: { userName: 'existing', alias: 'Existing' }
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: null
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: []
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips child hydration when child already has valid requestedUser', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: { userName: 'already_set', alias: 'AlreadySet' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('already_set');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ describe('QBittorrentClient', () => {
|
|||||||
downloaded: 750000000,
|
downloaded: 750000000,
|
||||||
speed: 1048576,
|
speed: 1048576,
|
||||||
eta: 3600,
|
eta: 3600,
|
||||||
|
seeds: 0,
|
||||||
|
peers: 0,
|
||||||
category: 'movies',
|
category: 'movies',
|
||||||
tags: ['movie', 'hd'],
|
tags: ['movie', 'hd'],
|
||||||
savePath: '/downloads/test',
|
savePath: '/downloads/test',
|
||||||
@@ -138,6 +140,28 @@ describe('QBittorrentClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => {
|
||||||
|
const torrent = {
|
||||||
|
hash: 'def456',
|
||||||
|
name: 'Swarm Torrent',
|
||||||
|
state: 'downloading',
|
||||||
|
progress: 0.1,
|
||||||
|
size: 1000,
|
||||||
|
completed: 100,
|
||||||
|
dlspeed: 0,
|
||||||
|
eta: 0,
|
||||||
|
num_seeds: 7,
|
||||||
|
num_leechs: 3,
|
||||||
|
// Swarm totals — must NOT be picked up as connected counts
|
||||||
|
num_complete: 200,
|
||||||
|
num_incomplete: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = client.normalizeDownload(torrent);
|
||||||
|
expect(normalized.seeds).toBe(7);
|
||||||
|
expect(normalized.peers).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown torrent states', () => {
|
it('should handle unknown torrent states', () => {
|
||||||
const torrent = {
|
const torrent = {
|
||||||
hash: 'abc123',
|
hash: 'abc123',
|
||||||
|
|||||||
@@ -420,4 +420,68 @@ describe('RTorrentClient', () => {
|
|||||||
expect(status).toBeNull();
|
expect(status).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Null-safety (Issue #68)', () => {
|
||||||
|
it('should return [] when d.multicall2 returns a non-array', async () => {
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, null);
|
||||||
|
});
|
||||||
|
const downloads = await client.getActiveDownloads();
|
||||||
|
expect(downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip malformed individual torrent rows instead of throwing', async () => {
|
||||||
|
const torrents = [
|
||||||
|
// valid row
|
||||||
|
['hashA', 'Name A', 100, 50, 0, 0, 1, 1, 0, '/dl', ''],
|
||||||
|
// malformed row (not an array)
|
||||||
|
'not-an-array',
|
||||||
|
// row with null/undefined fields
|
||||||
|
['hashB', null, null, null, null, null, null, null, null, null, null]
|
||||||
|
];
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, torrents);
|
||||||
|
});
|
||||||
|
const downloads = await client.getActiveDownloads();
|
||||||
|
expect(downloads).toHaveLength(2);
|
||||||
|
expect(downloads[0].id).toBe('hashA');
|
||||||
|
expect(downloads[1].id).toBe('hashB');
|
||||||
|
expect(downloads[1].title).toBe('');
|
||||||
|
expect(downloads[1].size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_extractArrInfo should return {} for non-string filename', () => {
|
||||||
|
expect(client._extractArrInfo(null)).toEqual({});
|
||||||
|
expect(client._extractArrInfo(undefined)).toEqual({});
|
||||||
|
expect(client._extractArrInfo(123)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastError tracking (Issue #68)', () => {
|
||||||
|
it('should record lastError on getActiveDownloads failure', async () => {
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(new Error('boom'));
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
expect(client.getLastError().operation).toBe('getActiveDownloads');
|
||||||
|
expect(client.getLastError().message).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear lastError on successful call', async () => {
|
||||||
|
// First, fail.
|
||||||
|
mockMethodCall.mockImplementationOnce((method, params, callback) => {
|
||||||
|
callback(new Error('boom'));
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
|
||||||
|
// Then, succeed.
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, []);
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -299,4 +299,63 @@ describe('SABnzbdClient', () => {
|
|||||||
expect(status).toBeNull();
|
expect(status).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('History limit configuration (Issue #68)', () => {
|
||||||
|
const ORIG_ENV = process.env.SAB_HISTORY_LIMIT;
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIG_ENV === undefined) delete process.env.SAB_HISTORY_LIMIT;
|
||||||
|
else process.env.SAB_HISTORY_LIMIT = ORIG_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults historyLimit to 10 when SAB_HISTORY_LIMIT is unset', () => {
|
||||||
|
delete process.env.SAB_HISTORY_LIMIT;
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors SAB_HISTORY_LIMIT when set to a valid integer', () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = '25';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default on invalid SAB_HISTORY_LIMIT', () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = 'not-a-number';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes historyLimit through to the history API call', async () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = '42';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
const makeRequest = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
||||||
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
||||||
|
c.makeRequest = makeRequest;
|
||||||
|
await c.getActiveDownloads();
|
||||||
|
expect(makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 42 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastError tracking (Issue #68)', () => {
|
||||||
|
it('records lastError when getActiveDownloads fails', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
expect(client.getLastError().operation).toBe('getActiveDownloads');
|
||||||
|
expect(client.getLastError().message).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears lastError after a subsequent successful call', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
|
||||||
|
client.makeRequest = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
||||||
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ describe('TransmissionClient', () => {
|
|||||||
4: 'Downloading',
|
4: 'Downloading',
|
||||||
5: 'Queued',
|
5: 'Queued',
|
||||||
6: 'Seeding',
|
6: 'Seeding',
|
||||||
7: 'Unknown'
|
// Issue #63: code 7 is undocumented in the RPC spec; mapped to
|
||||||
|
// `Checking` (legacy alias for code 2) as a best-effort interpretation.
|
||||||
|
7: 'Checking'
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
|
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
|
||||||
@@ -433,4 +435,42 @@ describe('TransmissionClient', () => {
|
|||||||
expect(normalized.arrType).toBeUndefined();
|
expect(normalized.arrType).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Torrent Control Methods (Issue #63)', () => {
|
||||||
|
it('startTorrent invokes torrent-start RPC with ids array', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.startTorrent('abc123');
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: ['abc123'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startTorrent accepts an array of ids', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.startTorrent([1, 2, 3]);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: [1, 2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopTorrent invokes torrent-stop RPC', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.stopTorrent(42);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-stop', { ids: [42] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeTorrent invokes torrent-remove with delete-local-data=false by default', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.removeTorrent('hashX');
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
|
||||||
|
ids: ['hashX'],
|
||||||
|
'delete-local-data': false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeTorrent passes delete-local-data=true when requested', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.removeTorrent('hashY', true);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
|
||||||
|
ids: ['hashY'],
|
||||||
|
'delete-local-data': true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -310,7 +310,8 @@ describe('DownloadClientRegistry', () => {
|
|||||||
instanceId: 'sab1',
|
instanceId: 'sab1',
|
||||||
instanceName: 'SAB 1',
|
instanceName: 'SAB 1',
|
||||||
clientType: 'sabnzbd',
|
clientType: 'sabnzbd',
|
||||||
status: { status: 'active' }
|
status: { status: 'active' },
|
||||||
|
lastError: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,40 @@ describe('getRequestStatus', () => {
|
|||||||
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||||
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns available from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns denied from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns approved from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows priority inside childRequests: available > denied > approved > pending', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ available: true, denied: true },
|
||||||
|
{ approved: true }
|
||||||
|
]})).toBe('available');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ denied: true, approved: true },
|
||||||
|
{ requested: true }
|
||||||
|
]})).toBe('denied');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ approved: true, requested: true }
|
||||||
|
]})).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for TV request with empty childRequests', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [] })).toBe('unknown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -79,6 +79,85 @@ describe('ombiHelpers', () => {
|
|||||||
};
|
};
|
||||||
expect(extractRequestedUser(req)).toBe('');
|
expect(extractRequestedUser(req)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns userName from nested user object', () => {
|
||||||
|
const req = { user: { userName: 'user_val' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('user_val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alias from nested requestedBy object', () => {
|
||||||
|
const req = { requestedBy: { alias: 'req_alias' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('req_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalizedUserName from nested ombiUser object', () => {
|
||||||
|
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('norm_ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns userAlias from nested requestedByUser object', () => {
|
||||||
|
const req = { requestedByUser: { userAlias: 'alias_user' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('alias_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from a string source value', () => {
|
||||||
|
const req = { requestedBy: 'direct_string' };
|
||||||
|
expect(extractRequestedUser(req)).toBe('direct_string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
|
||||||
|
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
|
||||||
|
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
|
||||||
|
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from seasons array requests', () => {
|
||||||
|
const req = {
|
||||||
|
seasons: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { alias: 'season_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('season_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests array', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ user: { userName: 'child_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('child_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { userName: 'tv_user', alias: 'tv_alias' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('tv_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser as string', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedUser: 'string_user' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('string_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedByAlias: 'deep_alias' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('deep_alias');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterRequestsByUser', () => {
|
describe('filterRequestsByUser', () => {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
// Tests for the shared `buildArrQueueCache` helper (Issue #61).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { buildArrQueueCache } = require('../../../server/utils/arrQueueHelpers');
|
||||||
|
|
||||||
|
const sonarrInstances = [
|
||||||
|
{ id: 's1', url: 'http://sonarr-1', apiKey: 'KEY_S1', name: 'Sonarr 1' }
|
||||||
|
];
|
||||||
|
const radarrInstances = [
|
||||||
|
{ id: 'r1', url: 'http://radarr-1', apiKey: 'KEY_R1', name: 'Radarr 1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildArrQueueCache', () => {
|
||||||
|
it('returns empty array for empty / missing input', () => {
|
||||||
|
expect(buildArrQueueCache([], sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(null, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(undefined, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for invalid mediaKey', () => {
|
||||||
|
const queues = [{ instance: 's1', data: { records: [{ id: 1 }] } }];
|
||||||
|
expect(buildArrQueueCache(queues, sonarrInstances, 'bogus')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Sonarr records with _instanceUrl/_instanceKey and decorates embedded series', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dl-1', series: { id: 100, title: 'X' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_S1');
|
||||||
|
expect(out[0].series._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Radarr records and decorates embedded movie', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 11, downloadId: 'dl-r1', movie: { id: 555, title: 'M' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://radarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_R1');
|
||||||
|
expect(out[0].movie._instanceUrl).toBe('http://radarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotates Sonarr season pack records (multiple entries sharing downloadId)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'pack-A', episodeId: 101 },
|
||||||
|
{ id: 2, downloadId: 'pack-A', episodeId: 102 },
|
||||||
|
{ id: 3, downloadId: 'pack-A', episodeId: 103 },
|
||||||
|
{ id: 4, downloadId: 'single-B', episodeId: 200 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(4);
|
||||||
|
const packMembers = out.filter(r => r.downloadId === 'pack-A');
|
||||||
|
expect(packMembers).toHaveLength(3);
|
||||||
|
for (const r of packMembers) {
|
||||||
|
expect(r.isSeasonPack).toBe(true);
|
||||||
|
expect(r.episodeCount).toBe(3);
|
||||||
|
}
|
||||||
|
const single = out.find(r => r.downloadId === 'single-B');
|
||||||
|
expect(single.isSeasonPack).toBeUndefined();
|
||||||
|
expect(single.episodeCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not annotate Radarr records as season packs even if downloadId repeats', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dup', movie: { id: 1 } },
|
||||||
|
{ id: 2, downloadId: 'dup', movie: { id: 2 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
for (const r of out) {
|
||||||
|
expect(r.isSeasonPack).toBeUndefined();
|
||||||
|
expect(r.episodeCount).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed records and continues', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
null,
|
||||||
|
{ id: 1, downloadId: 'ok', series: { id: 1 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ instance: 's1' } // no data property
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown instance id gracefully (null url/key)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'unknown-instance',
|
||||||
|
data: { records: [{ id: 1, downloadId: 'x' }] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBeNull();
|
||||||
|
expect(out[0]._instanceKey).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Set environment variables before requiring any modules
|
||||||
|
process.env.POLL_INTERVAL = '5000';
|
||||||
|
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||||
|
|
||||||
|
const cache = require('../../../server/utils/cache.js');
|
||||||
|
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||||
|
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||||
|
const config = require('../../../server/utils/config.js');
|
||||||
|
|
||||||
|
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||||
|
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||||
|
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||||
|
sabnzbd: [],
|
||||||
|
qbittorrent: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||||
|
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||||
|
movie: [],
|
||||||
|
tv: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||||
|
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||||
|
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||||
|
|
||||||
|
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||||
|
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||||
|
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||||
|
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: null
|
||||||
|
});
|
||||||
|
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Now require the poller
|
||||||
|
const poller = require('../../../server/utils/poller.js');
|
||||||
|
|
||||||
|
describe('Background Poller Utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-apply standard resolved values
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||||
|
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||||
|
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||||
|
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getOmbiInstancesSpy.mockReturnValue([]);
|
||||||
|
|
||||||
|
cacheSetSpy.mockImplementation(() => {});
|
||||||
|
cacheGetSpy.mockReturnValue(null);
|
||||||
|
getWebhookMetricsSpy.mockReturnValue(null);
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||||
|
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
poller.stopPoller();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Poller Core Logic', () => {
|
||||||
|
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||||
|
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||||
|
expect(poller.POLLING_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||||
|
let callbackFired = false;
|
||||||
|
const callback = () => {
|
||||||
|
callbackFired = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.onPollComplete(callback);
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(callbackFired).toBe(true);
|
||||||
|
|
||||||
|
// Clean up/Deregister callback
|
||||||
|
poller.offPollComplete(callback);
|
||||||
|
callbackFired = false;
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(callbackFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||||
|
// Stub initializeClients to delay using a promise
|
||||||
|
let resolveInit;
|
||||||
|
const delayPromise = new Promise((resolve) => {
|
||||||
|
resolveInit = resolve;
|
||||||
|
});
|
||||||
|
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||||
|
|
||||||
|
// Start the first poll (which remains pending on initializeClients)
|
||||||
|
const firstPollPromise = poller.pollAllServices();
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Trigger second poll immediately while first is in progress
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||||
|
|
||||||
|
// Resolve the delay to let the first poll finish
|
||||||
|
resolveInit();
|
||||||
|
await firstPollPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||||
|
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||||
|
|
||||||
|
// Setup error spy
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||||
|
|
||||||
|
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Webhook-Based Instance Bypassing', () => {
|
||||||
|
it('skips polling for an instance with recent active webhook events', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Verify that skips are incremented for both
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||||
|
|
||||||
|
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||||
|
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||||
|
const staleTimestamp = Date.now() - 11 * 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Should bypass the skip and perform a full poll
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent metrics on individual level but stale globally
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global webhook is stale
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
|
||||||
|
// Triggered immediately on start (flush microtasks)
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time by 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance by another 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user