Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40307a421 | |||
| 6c4aedf60e | |||
| 97e2f256e6 | |||
| 53eb19ba0c | |||
| 2f32edf77f | |||
| 0364a3c824 | |||
| 50e1e09e55 | |||
| bbc461ad6e | |||
| d29b6e9223 | |||
| df5328349b | |||
| b9c8c0be87 | |||
| 06818dbf29 | |||
| 7f7a91f056 | |||
| 1dc8d8a26c | |||
| af33e4ec43 | |||
| a4d398ef1b | |||
| 879aee8eea | |||
| 70710061b8 | |||
| f8f693e32a | |||
| 501a4c83bb | |||
| 6fa9c79a7d | |||
| 3d49c926dc | |||
| bd7a9c7951 | |||
| 4a5dc70548 | |||
| 498eabc7bc | |||
| 6b73727d4e | |||
| 593ad79670 |
+51
-1
@@ -2,7 +2,57 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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.36] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Test Timeout & Cross-Suite Background Event Pollution (V8 Coverage)** — Configured `fileParallelism: false` and `testTimeout: 15000` in `vitest.config.js`. This guarantees that slow code compilation/instrumentation under V8 coverage doesn't cause transient 5-second timeouts, and prevents asynchronous fire-and-forget background event loops (like Ombi webhook retry loops) in one test suite from running concurrently and overwriting cache singletons in other test suites.
|
||||||
|
|
||||||
|
## [1.7.35] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Orphaned *arr Queue Item Support (Issue #73)** — Added support for active Sonarr/Radarr queue items from unconfigured download clients ("orphaned" downloads). Added a new synthetic client (`'orphaned'`) with a custom viewBox vector graphics asset at `/images/clients/orphaned.svg` to represent unconfigured clients, and updated filter dropdown lists and active downloads grids to cleanly display them with a dimmed logo, custom dashed border styling, and informative hover tooltips. Resolves Gitea Issue [#73](https://git.i3omb.com/Gandalf/sofarr/issues/73).
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Download Matching & JSDoc Hygiene (Issue #73)** — Refactored core active-download matching algorithms into unified, deduplicated helper functions (`normalizeTitle`, `titleMatches`, `buildArrDownload`) in `DownloadMatcher.js`, preventing hundreds of lines of duplicate code. Handled case-insensitive and type-safe `downloadId` lookup in `matchSabHistory` across both history and active queue records. Added safe progress arithmetic bounds checking to prevent division-by-zero or `NaN`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Security Metadata Isolation in buildArrDownload (Issue #73)** — Restricted access control for sensitive properties like `arrInstanceKey` (the raw instance API key) to ensure they are strictly stripped out of download objects for non-administrator users, preserving system security boundaries.
|
||||||
|
|
||||||
|
## [1.7.34] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook Test Duplicate Error (Issue #71)** — Skipped duplicate/replay protection for `"Test"` event types in Sonarr and Radarr webhook handlers, resolving test button failures. Resolves Gitea Issue [#71](https://git.i3omb.com/Gandalf/sofarr/issues/71).
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Unified Tab Header Layout & Typography Consistency (Issue #72)** — Added consistent titles, subtitles, and icons across Active Downloads, Recently Completed, and Requests tab panels, and refactored styling to use a unified flexbox design with CSS variables. Resolves Gitea Issue [#72](https://git.i3omb.com/Gandalf/sofarr/issues/72).
|
||||||
|
|
||||||
|
## [1.7.33] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Requests Tab Layout Enhancement (Issue #69)** — Redesigned and unified the Requests tab container and card layouts with the Active Downloads and Recently Completed tabs. Added styled media-type borders (`tv` and `movie`) using system color variables, styled the `.requests-container` with a surface card background (`var(--surface)`) and box shadow, converted `.requests-list` to a column flexbox (`display: flex; flex-direction: column; gap: 8px;`), aligned card items to the top (`align-items: flex-start`), tighter padding (`10px 14px`), and border-radius (`6px`), and scaled `.request-type-icon` to `48px` wide and `68px` high as a clean cover-art placeholder. All changes are strictly scoped to the requests tab element selectors, leaving active and recent downloads 100% untouched. Resolves Gitea Issue [#69](https://git.i3omb.com/Gandalf/sofarr/issues/69).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook Regression (Issue #70)** — Fixed a critical regression introduced in #62 where the Ombi webhook handler called `isReplay()` with 3 arguments instead of the new 4-argument signature (`eventType, instanceName, eventDate, contentId`). The handler now correctly passes `requestId` as the fourth `contentId` argument. This restores reliability to real Ombi webhooks, loopback fallbacks, and the Ombi test simulation buttons. Resolves Gitea Issue [#70](https://git.i3omb.com/Gandalf/sofarr/issues/70).
|
||||||
|
|
||||||
|
## [1.7.32] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TransmissionClient Hardening (Issue #63)** — Mapped the previously-unknown Transmission RPC status code `7` to `Checking` (best-effort; the RPC spec formally documents only codes 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
|
## [1.7.31] - 2026-05-28
|
||||||
|
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ This approach provides:
|
|||||||
|
|
||||||
### Proxy Routes
|
### Proxy Routes
|
||||||
|
|
||||||
The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/emby/**) are authenticated proxies to upstream services. These endpoints reflect the APIs of Sonarr, Radarr, SABnzbd, and Emby respectively, and are documented to show the specific endpoints implemented in sofarr (not the full upstream API surface).
|
The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/emby/**) are authenticated proxies to upstream services. These expose a **selective subset** of endpoints from Sonarr, Radarr, SABnzbd, and Emby respectively — not the full upstream API surface. See the API Endpoints section below for the complete list of implemented proxy endpoints.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -474,10 +474,36 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
|
|||||||
- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting
|
- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting
|
||||||
|
|
||||||
### Service APIs (proxy to your services)
|
### Service APIs (proxy to your services)
|
||||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
- `GET /api/sabnzbd/queue` — SABnzbd queue
|
||||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
- `GET /api/sabnzbd/history` — SABnzbd history
|
||||||
- `GET /api/radarr/*` — Radarr API proxy
|
- `GET /api/sonarr/queue` — Sonarr queue
|
||||||
- `GET /api/emby/*` — Emby API proxy
|
- `GET /api/sonarr/history` — Sonarr history
|
||||||
|
- `GET /api/sonarr/series` — Sonarr series list
|
||||||
|
- `GET /api/sonarr/series/:id` — Sonarr series details
|
||||||
|
- `GET /api/sonarr/notifications` — Sonarr notifications list
|
||||||
|
- `GET /api/sonarr/notifications/:id` — Sonarr notification details
|
||||||
|
- `POST /api/sonarr/notifications` — Create Sonarr notification
|
||||||
|
- `PUT /api/sonarr/notifications/:id` — Update Sonarr notification
|
||||||
|
- `DELETE /api/sonarr/notifications/:id` — Delete Sonarr notification
|
||||||
|
- `POST /api/sonarr/notifications/test` — Test Sonarr notification
|
||||||
|
- `GET /api/sonarr/notifications/schema` — Sonarr notification schema
|
||||||
|
- `POST /api/sonarr/notifications/sofarr-webhook` — One-click Sofarr webhook setup
|
||||||
|
- `GET /api/radarr/queue` — Radarr queue
|
||||||
|
- `GET /api/radarr/history` — Radarr history
|
||||||
|
- `GET /api/radarr/movies` — Radarr movies list
|
||||||
|
- `GET /api/radarr/movies/:id` — Radarr movie details
|
||||||
|
- `GET /api/radarr/notifications` — Radarr notifications list
|
||||||
|
- `GET /api/radarr/notifications/:id` — Radarr notification details
|
||||||
|
- `POST /api/radarr/notifications` — Create Radarr notification
|
||||||
|
- `PUT /api/radarr/notifications/:id` — Update Radarr notification
|
||||||
|
- `DELETE /api/radarr/notifications/:id` — Delete Radarr notification
|
||||||
|
- `POST /api/radarr/notifications/test` — Test Radarr notification
|
||||||
|
- `GET /api/radarr/notifications/schema` — Radarr notification schema
|
||||||
|
- `POST /api/radarr/notifications/sofarr-webhook` — One-click Sofarr webhook setup
|
||||||
|
- `GET /api/emby/sessions` — Emby active sessions
|
||||||
|
- `GET /api/emby/users` — Emby users list
|
||||||
|
- `GET /api/emby/users/:id` — Emby user details
|
||||||
|
- `GET /api/emby/session/:sessionId/user` — Emby user from session
|
||||||
|
|
||||||
## Logging Levels
|
## Logging Levels
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,17 @@ export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
|
|||||||
function createClientLogo(download) {
|
function createClientLogo(download) {
|
||||||
const clientLogoWrapper = document.createElement('span');
|
const clientLogoWrapper = document.createElement('span');
|
||||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||||
|
if (download.isOrphaned) {
|
||||||
|
clientLogoWrapper.classList.add('orphaned-logo');
|
||||||
|
}
|
||||||
|
|
||||||
const clientLogo = document.createElement('img');
|
const clientLogo = document.createElement('img');
|
||||||
clientLogo.className = 'download-client-logo';
|
clientLogo.className = 'download-client-logo';
|
||||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||||
clientLogo.title = download.instanceName || download.client;
|
clientLogo.title = download.isOrphaned
|
||||||
|
? "This download is managed by a *arr instance's download client that is not configured in Sofarr."
|
||||||
|
: (download.instanceName || download.client);
|
||||||
clientLogo.onerror = () => {
|
clientLogo.onerror = () => {
|
||||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||||
clientLogoWrapper.classList.add('fallback');
|
clientLogoWrapper.classList.add('fallback');
|
||||||
@@ -303,7 +308,7 @@ export async function handleBlocklistSearchClick(btn, download) {
|
|||||||
|
|
||||||
export function createDownloadCard(download) {
|
export function createDownloadCard(download) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = `download-card ${download.type}`;
|
card.className = `download-card ${download.type}${download.isOrphaned ? ' orphaned' : ''}`;
|
||||||
card.dataset.id = download.title;
|
card.dataset.id = download.title;
|
||||||
|
|
||||||
// Cover art
|
// Cover art
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function createRequestCard(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'request-card';
|
card.className = `request-card ${request.mediaType || ''}`;
|
||||||
|
|
||||||
const typeIcon = document.createElement('span');
|
const typeIcon = document.createElement('span');
|
||||||
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
|
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
|
||||||
|
|||||||
@@ -12,7 +12,17 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
build: {
|
build: {
|
||||||
|
// NOTE (Issue #66): `outDir` is intentionally the repo-root `../public`,
|
||||||
|
// NOT the Vite default `client/dist/`. The Express server in
|
||||||
|
// `server/app.js` serves static assets directly from `public/`, so the
|
||||||
|
// Vite build emits its bundle alongside the hand-authored static assets
|
||||||
|
// (favicon, etc.) that live in `public/` and are committed to the repo.
|
||||||
|
// Do NOT change this back to `dist/` without also updating the Express
|
||||||
|
// static-serve configuration and the Dockerfile copy steps.
|
||||||
outDir: '../public',
|
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,
|
emptyOutDir: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.31",
|
"version": "1.7.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.31",
|
"version": "1.7.36",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.31",
|
"version": "1.7.36",
|
||||||
"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": {
|
||||||
|
|||||||
+4
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||||
|
<circle cx="256" cy="256" r="240" fill="#f5f5f7" stroke="#d2d2d7" stroke-width="20"/>
|
||||||
|
<text x="50%" y="60%" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="300px" font-weight="bold" fill="#86868b" text-anchor="middle" dominant-baseline="middle">?</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 409 B |
+18
-6
@@ -170,8 +170,12 @@
|
|||||||
|
|
||||||
<div class="tab-panel" id="tab-downloads">
|
<div class="tab-panel" id="tab-downloads">
|
||||||
<div class="downloads-container">
|
<div class="downloads-container">
|
||||||
<div class="downloads-header">
|
<div class="downloads-header tab-header">
|
||||||
<div class="downloads-controls">
|
<div class="tab-header-title">
|
||||||
|
<h2><span class="tab-header-icon">📥</span> Active Downloads</h2>
|
||||||
|
<p class="tab-header-subtitle">Track and manage your active media downloads in real-time</p>
|
||||||
|
</div>
|
||||||
|
<div class="downloads-controls tab-header-controls">
|
||||||
<label class="download-client-label" for="download-client-filter">Download client:</label>
|
<label class="download-client-label" for="download-client-filter">Download client:</label>
|
||||||
<div class="download-client-filter" id="download-client-filter">
|
<div class="download-client-filter" id="download-client-filter">
|
||||||
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
|
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
|
||||||
@@ -200,8 +204,12 @@
|
|||||||
|
|
||||||
<div class="tab-panel hidden" id="tab-requests">
|
<div class="tab-panel hidden" id="tab-requests">
|
||||||
<div class="requests-container">
|
<div class="requests-container">
|
||||||
<div class="requests-header">
|
<div class="requests-header tab-header">
|
||||||
<div class="requests-controls">
|
<div class="tab-header-title">
|
||||||
|
<h2><span class="tab-header-icon">📨</span> Requests</h2>
|
||||||
|
<p class="tab-header-subtitle">Browse, filter, and track requests synced from Ombi</p>
|
||||||
|
</div>
|
||||||
|
<div class="requests-controls tab-header-controls">
|
||||||
<!-- Media Type Filter -->
|
<!-- Media Type Filter -->
|
||||||
<div class="request-filter" id="request-type-filter">
|
<div class="request-filter" id="request-type-filter">
|
||||||
<label class="request-filter-label">Type:</label>
|
<label class="request-filter-label">Type:</label>
|
||||||
@@ -286,8 +294,12 @@
|
|||||||
|
|
||||||
<div class="tab-panel hidden" id="tab-history">
|
<div class="tab-panel hidden" id="tab-history">
|
||||||
<div class="history-container" id="history-container">
|
<div class="history-container" id="history-container">
|
||||||
<div class="history-header">
|
<div class="history-header tab-header">
|
||||||
<div class="history-controls">
|
<div class="tab-header-title">
|
||||||
|
<h2><span class="tab-header-icon">📜</span> Recently Completed</h2>
|
||||||
|
<p class="tab-header-subtitle">Review successful imports and troubleshoot failed upgrade attempts</p>
|
||||||
|
</div>
|
||||||
|
<div class="history-controls tab-header-controls">
|
||||||
<label class="history-days-label" for="history-days">Last</label>
|
<label class="history-days-label" for="history-days">Last</label>
|
||||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||||
<span class="history-days-label">days</span>
|
<span class="history-days-label">days</span>
|
||||||
|
|||||||
+91
-42
@@ -689,15 +689,61 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Downloads header and controls */
|
/* Unified Tab Headers (Issue #72) */
|
||||||
.downloads-header {
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header-icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-header, .tab-header-title h2, .tab-header-subtitle {
|
||||||
|
transition: color 0.3s, border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-header {
|
||||||
|
/* Inherits from .tab-header */
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-controls {
|
.downloads-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -898,11 +944,7 @@ body {
|
|||||||
/* ===== Request Filters ===== */
|
/* ===== Request Filters ===== */
|
||||||
|
|
||||||
.requests-header {
|
.requests-header {
|
||||||
display: flex;
|
/* Inherits from .tab-header */
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.requests-controls {
|
.requests-controls {
|
||||||
@@ -1076,18 +1118,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-header {
|
.history-header {
|
||||||
display: flex;
|
/* Inherits from .tab-header */
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-controls {
|
.history-controls {
|
||||||
@@ -1134,7 +1165,7 @@ body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -2229,17 +2260,15 @@ body {
|
|||||||
|
|
||||||
/* ===== Requests Tab ===== */
|
/* ===== Requests Tab ===== */
|
||||||
.requests-container {
|
.requests-container {
|
||||||
padding: 20px;
|
background: var(--surface);
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px var(--shadow);
|
||||||
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requests-header {
|
.requests-header {
|
||||||
margin-bottom: 20px;
|
/* Inherits from .tab-header */
|
||||||
}
|
|
||||||
|
|
||||||
.requests-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-requests {
|
.no-requests {
|
||||||
@@ -2249,37 +2278,46 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.requests-list {
|
.requests-list {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
overflow-x: hidden;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card {
|
.request-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 10px 14px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-card.tv {
|
||||||
|
border-left: 3px solid var(--series-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card.movie {
|
||||||
|
border-left: 3px solid var(--movie-color);
|
||||||
|
}
|
||||||
|
|
||||||
.request-card:hover {
|
.request-card:hover {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-type-icon {
|
.request-type-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1.6rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 48px;
|
||||||
height: 40px;
|
height: 68px;
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 4px var(--shadow-strong);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2289,11 +2327,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.request-title {
|
.request-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin: 0 0 4px;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-meta {
|
.request-meta {
|
||||||
@@ -2381,3 +2419,14 @@ body {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Orphaned Download Styling ===== */
|
||||||
|
.download-card.orphaned {
|
||||||
|
border-left: 3px dashed var(--border-color, #c8c8cc);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.download-client-logo-wrapper.orphaned-logo {
|
||||||
|
filter: grayscale(1) opacity(0.5);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.31"
|
* example: "1.7.36"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,8 +169,12 @@ 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);
|
||||||
if (seriesMatch) {
|
if (seriesMatch) {
|
||||||
@@ -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;
|
||||||
|
|||||||
+3
-3
@@ -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.31
|
version: 1.7.36
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -46,9 +46,9 @@ tags:
|
|||||||
- name: Webhook
|
- name: Webhook
|
||||||
description: Webhook receivers for Sonarr/Radarr
|
description: Webhook receivers for Sonarr/Radarr
|
||||||
- name: Sonarr
|
- name: Sonarr
|
||||||
description: Sonarr API proxy
|
description: Selective Sonarr API proxy (specific endpoints only)
|
||||||
- name: Radarr
|
- name: Radarr
|
||||||
description: Radarr API proxy
|
description: Selective Radarr API proxy (specific endpoints only)
|
||||||
- name: SABnzbd
|
- name: SABnzbd
|
||||||
description: SABnzbd API proxy
|
description: SABnzbd API proxy
|
||||||
- name: Emby
|
- name: Emby
|
||||||
|
|||||||
@@ -528,6 +528,16 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
type: c.getClientType()
|
type: c.getClientType()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Append orphaned synthetic client entry if orphaned downloads exist
|
||||||
|
const hasOrphaned = userDownloads.some(dl => dl && dl.isOrphaned);
|
||||||
|
if (hasOrphaned && !downloadClients.some(c => c.id === 'orphaned')) {
|
||||||
|
downloadClients.push({
|
||||||
|
id: 'orphaned',
|
||||||
|
name: 'Orphaned (unconfigured client)',
|
||||||
|
type: 'orphaned'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Filter Ombi requests by user if not admin or if showAll is false
|
// Filter Ombi requests by user if not admin or if showAll is false
|
||||||
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
|
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
|
||||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||||
|
|||||||
+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 });
|
||||||
|
|||||||
+38
-32
@@ -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;
|
||||||
@@ -202,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
|
|||||||
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, payload = null) {
|
|||||||
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))`);
|
||||||
}
|
}
|
||||||
@@ -480,11 +466,21 @@ 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;
|
||||||
|
|
||||||
|
// Skip replay protection for Test events
|
||||||
|
if (eventType === "Test") {
|
||||||
|
logToFile(`[Webhook] Sonarr Test event received — skipping replay protection`);
|
||||||
|
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,11 +630,21 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
const { eventType, instanceName, eventDate } = validation;
|
const { 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;
|
||||||
|
|
||||||
|
// Skip replay protection for Test events
|
||||||
|
if (eventType === "Test") {
|
||||||
|
logToFile(`[Webhook] Radarr Test event received — skipping replay protection`);
|
||||||
|
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,10 +819,10 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
|
|
||||||
// Use applicationUrl as instance identifier for replay protection
|
// Use applicationUrl as instance identifier for replay protection
|
||||||
const instanceName = applicationUrl || 'ombi';
|
const instanceName = applicationUrl || 'ombi';
|
||||||
// Use requestId + eventType + current time as replay key
|
|
||||||
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
||||||
|
const contentId = requestId || null;
|
||||||
|
|
||||||
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
if (isReplay(eventType, instanceName, eventDate, contentId)) {
|
||||||
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
|||||||
// Match all download sources
|
// Match all download sources
|
||||||
const userDownloads = [];
|
const userDownloads = [];
|
||||||
const seenDownloadKeys = new Set();
|
const seenDownloadKeys = new Set();
|
||||||
|
const matchedArrQueueIds = new Set();
|
||||||
|
|
||||||
if (sabnzbdQueue.data?.queue?.slots) {
|
if (sabnzbdQueue.data?.queue?.slots) {
|
||||||
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||||
@@ -80,6 +81,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
|||||||
if (!seenDownloadKeys.has(key)) {
|
if (!seenDownloadKeys.has(key)) {
|
||||||
seenDownloadKeys.add(key);
|
seenDownloadKeys.add(key);
|
||||||
userDownloads.push(dl);
|
userDownloads.push(dl);
|
||||||
|
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +93,24 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
|||||||
if (!seenDownloadKeys.has(key)) {
|
if (!seenDownloadKeys.has(key)) {
|
||||||
seenDownloadKeys.add(key);
|
seenDownloadKeys.add(key);
|
||||||
userDownloads.push(dl);
|
userDownloads.push(dl);
|
||||||
|
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||||
for (const dl of torrentMatches) {
|
for (const dl of torrentMatches) {
|
||||||
|
const key = `${dl.type}:${dl.title}`;
|
||||||
|
if (!seenDownloadKeys.has(key)) {
|
||||||
|
seenDownloadKeys.add(key);
|
||||||
|
userDownloads.push(dl);
|
||||||
|
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Match orphaned records that have no active download client counterpart
|
||||||
|
const orphanedMatches = await DownloadMatcher.matchOrphanedArrRecords(matchedArrQueueIds, context);
|
||||||
|
for (const dl of orphanedMatches) {
|
||||||
const key = `${dl.type}:${dl.title}`;
|
const key = `${dl.type}:${dl.title}`;
|
||||||
if (!seenDownloadKeys.has(key)) {
|
if (!seenDownloadKeys.has(key)) {
|
||||||
seenDownloadKeys.add(key);
|
seenDownloadKeys.add(key);
|
||||||
|
|||||||
+399
-396
@@ -9,6 +9,140 @@
|
|||||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||||
const TagMatcher = require('./TagMatcher');
|
const TagMatcher = require('./TagMatcher');
|
||||||
const DownloadAssembler = require('./DownloadAssembler');
|
const DownloadAssembler = require('./DownloadAssembler');
|
||||||
|
const { logToFile } = require('../utils/logger');
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
debug: (msg) => logToFile(`[DEBUG] ${msg}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a title by lowercase conversion and replacing periods/underscores/dashes with spaces.
|
||||||
|
* @param {string} str - The title to normalize
|
||||||
|
* @returns {string} Normalized title
|
||||||
|
*/
|
||||||
|
function normalizeTitle(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\./g, ' ')
|
||||||
|
.replace(/[\-_]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a download client item name with a *arr title by checking both raw
|
||||||
|
* and normalized (dots/dashes/underscores to spaces) forms bidirectionally.
|
||||||
|
* Only logs on title fallback matches (when isFallback=true) to keep logs clean.
|
||||||
|
*/
|
||||||
|
function titleMatches(clientName, arrTitle, { isFallback = true, caller = 'DownloadMatcher' } = {}) {
|
||||||
|
if (!clientName || !arrTitle) return false;
|
||||||
|
const a = clientName.toLowerCase();
|
||||||
|
const b = arrTitle.toLowerCase();
|
||||||
|
const aNorm = normalizeTitle(clientName);
|
||||||
|
const bNorm = normalizeTitle(arrTitle);
|
||||||
|
|
||||||
|
const matched = a.includes(b) || b.includes(a) ||
|
||||||
|
aNorm.includes(bNorm) || bNorm.includes(aNorm) ||
|
||||||
|
aNorm.includes(b) || b.includes(aNorm) ||
|
||||||
|
a.includes(bNorm) || bNorm.includes(a);
|
||||||
|
|
||||||
|
if (matched && isFallback) {
|
||||||
|
logger.debug(`[DownloadMatcher] Title fallback match in ${caller} after normalization: "${clientName}" <-> "${arrTitle}"`);
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options.
|
||||||
|
* Defaults exist only as a last-resort safety net.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* buildArrDownload(sonarrMatch, context, { client: 'sabnzbd', instanceId: 'sabnzbd-default', instanceName: 'SABnzbd' })
|
||||||
|
*/
|
||||||
|
function buildArrDownload(record, context, options = {}) {
|
||||||
|
const {
|
||||||
|
seriesMap,
|
||||||
|
moviesMap,
|
||||||
|
sonarrTagMap,
|
||||||
|
radarrTagMap,
|
||||||
|
username,
|
||||||
|
isAdmin,
|
||||||
|
showAll,
|
||||||
|
embyUserMap
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
// Detect if sonarr or radarr record
|
||||||
|
const isSeries = record.seriesId !== undefined || options.arrType === 'sonarr';
|
||||||
|
const mediaMap = isSeries ? seriesMap : moviesMap;
|
||||||
|
const tagMap = isSeries ? sonarrTagMap : radarrTagMap;
|
||||||
|
const mediaId = isSeries ? record.seriesId : record.movieId;
|
||||||
|
|
||||||
|
const media = mediaMap.get(mediaId) || record.series || record.movie;
|
||||||
|
if (!media) return null;
|
||||||
|
|
||||||
|
// Tag-based user filtering
|
||||||
|
const allTags = TagMatcher.extractAllTags(media.tags, tagMap);
|
||||||
|
const matchedUserTag = TagMatcher.extractUserTag(media.tags, tagMap, username);
|
||||||
|
if (!showAll && !matchedUserTag) return null;
|
||||||
|
|
||||||
|
// Safer default progress of 0 for items that haven't started yet
|
||||||
|
const progress = options.progress !== undefined ? options.progress : 0;
|
||||||
|
|
||||||
|
const dlObj = {
|
||||||
|
type: isSeries ? 'series' : 'movie',
|
||||||
|
title: options.title || record.title || record.sourceTitle,
|
||||||
|
coverArt: DownloadAssembler.getCoverArt(media),
|
||||||
|
status: options.status || record.status || 'Unknown',
|
||||||
|
progress,
|
||||||
|
mb: options.mb !== undefined ? options.mb : (record.size ? Math.round(record.size / 1024 / 1024) : 0),
|
||||||
|
size: options.size !== undefined ? options.size : (record.size || 0),
|
||||||
|
completedAt: options.completedAt || record.completed_time || null,
|
||||||
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
|
// Strict neutral defaults to avoid incorrect SABnzbd-centric data
|
||||||
|
client: options.client || 'orphaned',
|
||||||
|
instanceId: options.instanceId || 'orphaned',
|
||||||
|
instanceName: options.instanceName || 'Unknown',
|
||||||
|
...options.overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSeries) {
|
||||||
|
dlObj.seriesName = media.title;
|
||||||
|
dlObj.episodes = options.episodes || [];
|
||||||
|
} else {
|
||||||
|
dlObj.movieName = media.title;
|
||||||
|
dlObj.movieInfo = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = DownloadAssembler.getImportIssues(record);
|
||||||
|
if (issues) dlObj.importIssues = issues;
|
||||||
|
|
||||||
|
dlObj.arrQueueId = record.id;
|
||||||
|
dlObj.arrType = isSeries ? 'sonarr' : 'radarr';
|
||||||
|
dlObj.arrInstanceUrl = record._instanceUrl || null;
|
||||||
|
dlObj.arrContentId = record.episodeId || record.movieId || null;
|
||||||
|
dlObj.arrContentIds = record.episodeIds || null;
|
||||||
|
dlObj.arrSeriesId = record.seriesId || null;
|
||||||
|
dlObj.arrContentType = isSeries ? 'episode' : 'movie';
|
||||||
|
|
||||||
|
// Use correct blocklist determination
|
||||||
|
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
dlObj.downloadPath = options.downloadPath || null;
|
||||||
|
dlObj.targetPath = media.path || null;
|
||||||
|
dlObj.arrInstanceKey = record._instanceKey || null;
|
||||||
|
dlObj.arrLink = isSeries
|
||||||
|
? DownloadAssembler.getSonarrLink(media)
|
||||||
|
: DownloadAssembler.getRadarrLink(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
addOmbiMatching(dlObj, media, context);
|
||||||
|
|
||||||
|
return dlObj;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Map of series metadata from Sonarr queue and history records.
|
* Builds a Map of series metadata from Sonarr queue and history records.
|
||||||
@@ -90,19 +224,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
sonarrHistoryRecords,
|
sonarrHistoryRecords,
|
||||||
radarrQueueRecords,
|
radarrQueueRecords,
|
||||||
radarrHistoryRecords,
|
radarrHistoryRecords,
|
||||||
seriesMap,
|
|
||||||
moviesMap,
|
|
||||||
sonarrTagMap,
|
|
||||||
radarrTagMap,
|
|
||||||
username,
|
|
||||||
isAdmin,
|
|
||||||
showAll,
|
|
||||||
embyUserMap,
|
|
||||||
queueStatus,
|
queueStatus,
|
||||||
queueSpeed,
|
queueSpeed,
|
||||||
queueKbpersec,
|
queueKbpersec
|
||||||
ombiRetriever,
|
|
||||||
ombiBaseUrl
|
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
const matched = [];
|
const matched = [];
|
||||||
@@ -113,9 +237,6 @@ async function matchSabSlots(slots, context) {
|
|||||||
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
||||||
const nzbNameLower = nzbName.toLowerCase();
|
const nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
// Normalize SAB name (dots to spaces) for better matching
|
|
||||||
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
|
||||||
|
|
||||||
// Try to match by downloadId first (most reliable)
|
// Try to match by downloadId first (most reliable)
|
||||||
const sabDownloadId = slot.nzo_id || slot.id;
|
const sabDownloadId = slot.nzo_id || slot.id;
|
||||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||||
@@ -132,157 +253,70 @@ async function matchSabSlots(slots, context) {
|
|||||||
// Fallback: Check by title matching
|
// Fallback: Check by title matching
|
||||||
if (!sonarrMatch) {
|
if (!sonarrMatch) {
|
||||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = r.title || r.sourceTitle;
|
||||||
return rTitle && (
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
||||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!radarrMatch) {
|
if (!radarrMatch) {
|
||||||
radarrMatch = radarrQueueRecords.find(r => {
|
radarrMatch = radarrQueueRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = r.title || r.sourceTitle;
|
||||||
return rTitle && (
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
||||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check HISTORY (completed downloads) if no queue match
|
// Also check HISTORY (completed downloads) if no queue match
|
||||||
if (!sonarrMatch) {
|
if (!sonarrMatch) {
|
||||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = r.title || r.sourceTitle;
|
||||||
return rTitle && (
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
||||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!radarrMatch) {
|
if (!radarrMatch) {
|
||||||
radarrMatch = radarrHistoryRecords.find(r => {
|
radarrMatch = radarrHistoryRecords.find(r => {
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const rTitle = r.title || r.sourceTitle;
|
||||||
return rTitle && (
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
||||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
// Progress calculation
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||||
if (series) {
|
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
: 0;
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||||
// Calculate progress from SABnzbd slot data
|
|
||||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
|
||||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
|
||||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
|
||||||
: 0;
|
|
||||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
|
||||||
|
|
||||||
const dlObj = {
|
const commonOptions = {
|
||||||
type: 'series',
|
title: nzbName,
|
||||||
title: nzbName,
|
status: slotState.status,
|
||||||
coverArt: DownloadAssembler.getCoverArt(series),
|
progress: Math.round(progress),
|
||||||
status: slotState.status,
|
mb: slot.mb,
|
||||||
progress: Math.round(progress),
|
size: Math.round(slot.mb * 1024 * 1024),
|
||||||
mb: slot.mb,
|
client: 'sabnzbd',
|
||||||
mbmissing: slot.mbleft,
|
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||||
size: Math.round(slot.mb * 1024 * 1024),
|
instanceName: slot.instanceName || 'SABnzbd',
|
||||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
downloadPath: slot.storage || null,
|
||||||
eta: slot.timeleft,
|
overrides: {
|
||||||
seriesName: series.title,
|
mbmissing: slot.mbleft,
|
||||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
|
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||||
allTags,
|
eta: slot.timeleft
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
|
||||||
client: 'sabnzbd',
|
|
||||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
|
||||||
instanceName: slot.instanceName || 'SABnzbd'
|
|
||||||
};
|
|
||||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
|
||||||
if (issues) dlObj.importIssues = issues;
|
|
||||||
// Expose ARR IDs to non-admins for blocklist functionality
|
|
||||||
dlObj.arrQueueId = sonarrMatch.id;
|
|
||||||
dlObj.arrType = 'sonarr';
|
|
||||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
|
||||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
|
||||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
|
||||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
|
||||||
dlObj.arrContentType = 'episode';
|
|
||||||
if (isAdmin) {
|
|
||||||
dlObj.downloadPath = slot.storage || null;
|
|
||||||
dlObj.targetPath = series.path || null;
|
|
||||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
|
||||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
|
||||||
}
|
|
||||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
|
||||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
|
||||||
}
|
|
||||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
|
||||||
addOmbiMatching(dlObj, series, context);
|
|
||||||
matched.push(dlObj);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
|
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||||
|
...commonOptions,
|
||||||
|
arrType: 'sonarr',
|
||||||
|
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords)
|
||||||
|
});
|
||||||
|
if (dlObj) matched.push(dlObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||||
if (movie) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
arrType: 'radarr'
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
});
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (dlObj) matched.push(dlObj);
|
||||||
// Calculate progress from SABnzbd slot data
|
|
||||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
|
||||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
|
||||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
|
||||||
: 0;
|
|
||||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
|
||||||
|
|
||||||
const dlObj = {
|
|
||||||
type: 'movie',
|
|
||||||
title: nzbName,
|
|
||||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
|
||||||
status: slotState.status,
|
|
||||||
progress: Math.round(progress),
|
|
||||||
mb: slot.mb,
|
|
||||||
mbmissing: slot.mbleft,
|
|
||||||
size: Math.round(slot.mb * 1024 * 1024),
|
|
||||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
|
||||||
eta: slot.timeleft,
|
|
||||||
movieName: movie.title,
|
|
||||||
movieInfo: radarrMatch,
|
|
||||||
allTags,
|
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
|
||||||
client: 'sabnzbd',
|
|
||||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
|
||||||
instanceName: slot.instanceName || 'SABnzbd'
|
|
||||||
};
|
|
||||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
|
||||||
if (issues) dlObj.importIssues = issues;
|
|
||||||
// Expose ARR IDs to non-admins for blocklist functionality
|
|
||||||
dlObj.arrQueueId = radarrMatch.id;
|
|
||||||
dlObj.arrType = 'radarr';
|
|
||||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
|
||||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
|
||||||
dlObj.arrContentType = 'movie';
|
|
||||||
if (isAdmin) {
|
|
||||||
dlObj.downloadPath = slot.storage || null;
|
|
||||||
dlObj.targetPath = movie.path || null;
|
|
||||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
|
||||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
|
||||||
}
|
|
||||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
|
||||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
|
||||||
}
|
|
||||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
|
||||||
addOmbiMatching(dlObj, movie, context);
|
|
||||||
matched.push(dlObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matched;
|
return matched;
|
||||||
@@ -296,18 +330,10 @@ async function matchSabSlots(slots, context) {
|
|||||||
*/
|
*/
|
||||||
async function matchSabHistory(slots, context) {
|
async function matchSabHistory(slots, context) {
|
||||||
const {
|
const {
|
||||||
|
sonarrQueueRecords,
|
||||||
sonarrHistoryRecords,
|
sonarrHistoryRecords,
|
||||||
radarrHistoryRecords,
|
radarrQueueRecords,
|
||||||
seriesMap,
|
radarrHistoryRecords
|
||||||
moviesMap,
|
|
||||||
sonarrTagMap,
|
|
||||||
radarrTagMap,
|
|
||||||
username,
|
|
||||||
isAdmin,
|
|
||||||
showAll,
|
|
||||||
embyUserMap,
|
|
||||||
ombiRetriever,
|
|
||||||
ombiBaseUrl
|
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
const matched = [];
|
const matched = [];
|
||||||
@@ -316,82 +342,67 @@ async function matchSabHistory(slots, context) {
|
|||||||
if (!nzbName) continue;
|
if (!nzbName) continue;
|
||||||
const nzbNameLower = nzbName.toLowerCase();
|
const nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
const sonarrMatch = sonarrHistoryRecords.find(r => {
|
// Try to match by downloadId (nzo_id or slot ID) first (most reliable)
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
const sabDownloadId = slot.nzo_id || slot.id;
|
||||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
const matchesSabId = (r) => {
|
||||||
});
|
const dl = r && r.downloadId;
|
||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (!dl || !sabDownloadId) return false;
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
return String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
|
||||||
if (series) {
|
};
|
||||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
let sonarrMatch = sabDownloadId ? sonarrHistoryRecords.find(matchesSabId) : null;
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
let radarrMatch = sabDownloadId ? radarrHistoryRecords.find(matchesSabId) : null;
|
||||||
const dlObj = {
|
|
||||||
type: 'series',
|
// Dual-lookup: also try against active queue records (history slot may still be in *arr queue)
|
||||||
title: nzbName,
|
if (!sonarrMatch && sabDownloadId) {
|
||||||
coverArt: DownloadAssembler.getCoverArt(series),
|
sonarrMatch = sonarrQueueRecords.find(matchesSabId);
|
||||||
status: slot.status,
|
}
|
||||||
progress: 100, // History items are completed
|
if (!radarrMatch && sabDownloadId) {
|
||||||
mb: slot.mb,
|
radarrMatch = radarrQueueRecords.find(matchesSabId);
|
||||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
}
|
||||||
completedAt: slot.completed_time,
|
|
||||||
seriesName: series.title,
|
// Fallback: Check by title matching
|
||||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
|
if (!sonarrMatch) {
|
||||||
allTags,
|
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||||
matchedUserTag: matchedUserTag || null,
|
const rTitle = r.title || r.sourceTitle;
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
|
||||||
client: 'sabnzbd',
|
});
|
||||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
}
|
||||||
instanceName: slot.instanceName || 'SABnzbd'
|
if (!radarrMatch) {
|
||||||
};
|
radarrMatch = radarrHistoryRecords.find(r => {
|
||||||
if (isAdmin) {
|
const rTitle = r.title || r.sourceTitle;
|
||||||
dlObj.downloadPath = slot.storage || null;
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
|
||||||
dlObj.targetPath = series.path || null;
|
});
|
||||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
}
|
||||||
}
|
|
||||||
addOmbiMatching(dlObj, series, context);
|
const commonOptions = {
|
||||||
matched.push(dlObj);
|
title: nzbName,
|
||||||
}
|
status: slot.status || 'Completed',
|
||||||
}
|
progress: 100, // History items are completed
|
||||||
|
mb: slot.mb,
|
||||||
|
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||||
|
completedAt: slot.completed_time,
|
||||||
|
client: 'sabnzbd',
|
||||||
|
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||||
|
instanceName: slot.instanceName || 'SABnzbd',
|
||||||
|
downloadPath: slot.storage || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
|
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||||
|
...commonOptions,
|
||||||
|
arrType: 'sonarr',
|
||||||
|
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords)
|
||||||
|
});
|
||||||
|
if (dlObj) matched.push(dlObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMatch = radarrHistoryRecords.find(r => {
|
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||||
if (movie) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
arrType: 'radarr'
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
});
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (dlObj) matched.push(dlObj);
|
||||||
const dlObj = {
|
|
||||||
type: 'movie',
|
|
||||||
title: nzbName,
|
|
||||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
|
||||||
status: slot.status,
|
|
||||||
progress: 100, // History items are completed
|
|
||||||
mb: slot.mb,
|
|
||||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
|
||||||
completedAt: slot.completed_time,
|
|
||||||
movieName: movie.title,
|
|
||||||
movieInfo: radarrMatch,
|
|
||||||
allTags,
|
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
|
||||||
client: 'sabnzbd',
|
|
||||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
|
||||||
instanceName: slot.instanceName || 'SABnzbd'
|
|
||||||
};
|
|
||||||
if (isAdmin) {
|
|
||||||
dlObj.downloadPath = slot.storage || null;
|
|
||||||
dlObj.targetPath = movie.path || null;
|
|
||||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
|
||||||
}
|
|
||||||
addOmbiMatching(dlObj, movie, context);
|
|
||||||
matched.push(dlObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matched;
|
return matched;
|
||||||
@@ -408,17 +419,7 @@ async function matchTorrents(torrents, context) {
|
|||||||
sonarrQueueRecords,
|
sonarrQueueRecords,
|
||||||
sonarrHistoryRecords,
|
sonarrHistoryRecords,
|
||||||
radarrQueueRecords,
|
radarrQueueRecords,
|
||||||
radarrHistoryRecords,
|
radarrHistoryRecords
|
||||||
seriesMap,
|
|
||||||
moviesMap,
|
|
||||||
sonarrTagMap,
|
|
||||||
radarrTagMap,
|
|
||||||
username,
|
|
||||||
isAdmin,
|
|
||||||
showAll,
|
|
||||||
embyUserMap,
|
|
||||||
ombiRetriever,
|
|
||||||
ombiBaseUrl
|
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
const matched = [];
|
const matched = [];
|
||||||
@@ -427,177 +428,177 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (!torrentName) continue;
|
if (!torrentName) continue;
|
||||||
const torrentNameLower = torrentName.toLowerCase();
|
const torrentNameLower = torrentName.toLowerCase();
|
||||||
|
|
||||||
let matchedAny = false;
|
// Hash-first matching (Issue #65)
|
||||||
|
const torrentHash = torrent?.hash || torrent?.hashString || null;
|
||||||
|
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
|
||||||
|
const matchesByHash = (r) => {
|
||||||
|
const dl = r && r.downloadId;
|
||||||
|
if (!dl || !hashLower) return false;
|
||||||
|
return String(dl).toLowerCase() === hashLower;
|
||||||
|
};
|
||||||
|
|
||||||
|
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
|
||||||
|
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
|
||||||
|
|
||||||
|
// Fallback: Check by title matching
|
||||||
|
if (!sonarrMatch) {
|
||||||
|
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||||
|
const rTitle = r.title || r.sourceTitle;
|
||||||
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!radarrMatch) {
|
||||||
|
radarrMatch = radarrQueueRecords.find(r => {
|
||||||
|
const rTitle = r.title || r.sourceTitle;
|
||||||
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to history matching
|
||||||
|
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
|
||||||
|
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
|
||||||
|
|
||||||
|
if (!sonarrHistoryMatch) {
|
||||||
|
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||||
|
const rTitle = r.title || r.sourceTitle;
|
||||||
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!radarrHistoryMatch) {
|
||||||
|
radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||||
|
const rTitle = r.title || r.sourceTitle;
|
||||||
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper options for torrent mapping
|
||||||
|
const download = mapTorrentToDownload(torrent);
|
||||||
|
const progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||||
|
const speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||||
|
|
||||||
|
const commonOptions = {
|
||||||
|
title: torrentName,
|
||||||
|
status: download.status || torrent.status || 'Downloading',
|
||||||
|
progress: Math.round(progress),
|
||||||
|
mb: download.size ? Math.round(download.size / 1024 / 1024) : 0,
|
||||||
|
size: download.size || torrent.size || 0,
|
||||||
|
client: download.client || 'qbittorrent',
|
||||||
|
instanceId: torrent.instanceId || download.instanceId || 'qbittorrent-default',
|
||||||
|
instanceName: torrent.instanceName || download.instanceName || 'qBittorrent',
|
||||||
|
downloadPath: download.savePath || torrent.savePath || null,
|
||||||
|
overrides: {
|
||||||
|
id: download.hash || torrent.hash,
|
||||||
|
speed,
|
||||||
|
eta: torrent.eta,
|
||||||
|
seeds: torrent.seeds,
|
||||||
|
peers: torrent.peers,
|
||||||
|
availability: torrent.availability,
|
||||||
|
addedOn: torrent.addedOn,
|
||||||
|
qbittorrent: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sonarrMatch = sonarrQueueRecords.find(r => {
|
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||||
if (series) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
arrType: 'sonarr',
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords)
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
});
|
||||||
const download = mapTorrentToDownload(torrent);
|
if (dlObj) matched.push(dlObj);
|
||||||
download.id = download.hash || torrent.hash;
|
|
||||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
|
||||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
|
||||||
Object.assign(download, {
|
|
||||||
type: 'series',
|
|
||||||
coverArt: DownloadAssembler.getCoverArt(series),
|
|
||||||
seriesName: series.title,
|
|
||||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords),
|
|
||||||
allTags,
|
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
|
||||||
});
|
|
||||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
|
||||||
if (issues) download.importIssues = issues;
|
|
||||||
// Expose ARR IDs to non-admins for blocklist functionality
|
|
||||||
download.arrQueueId = sonarrMatch.id;
|
|
||||||
download.arrType = 'sonarr';
|
|
||||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
|
||||||
download.arrContentId = sonarrMatch.episodeId || null;
|
|
||||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
|
||||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
|
||||||
download.arrContentType = 'episode';
|
|
||||||
if (isAdmin) {
|
|
||||||
download.downloadPath = download.savePath || null;
|
|
||||||
download.targetPath = series.path || null;
|
|
||||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
|
||||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
|
||||||
}
|
|
||||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
|
||||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
|
||||||
}
|
|
||||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
|
||||||
addOmbiMatching(download, series, context);
|
|
||||||
matched.push(download);
|
|
||||||
matchedAny = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMatch = radarrQueueRecords.find(r => {
|
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||||
if (movie) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
arrType: 'radarr'
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
});
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (dlObj) matched.push(dlObj);
|
||||||
const download = mapTorrentToDownload(torrent);
|
|
||||||
download.id = download.hash || torrent.hash;
|
|
||||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
|
||||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
|
||||||
Object.assign(download, {
|
|
||||||
type: 'movie',
|
|
||||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
|
||||||
movieName: movie.title,
|
|
||||||
movieInfo: radarrMatch,
|
|
||||||
allTags,
|
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
|
||||||
});
|
|
||||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
|
||||||
if (issues) download.importIssues = issues;
|
|
||||||
// Expose ARR IDs to non-admins for blocklist functionality
|
|
||||||
download.arrQueueId = radarrMatch.id;
|
|
||||||
download.arrType = 'radarr';
|
|
||||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
|
||||||
download.arrContentId = radarrMatch.movieId || null;
|
|
||||||
download.arrContentType = 'movie';
|
|
||||||
if (isAdmin) {
|
|
||||||
download.downloadPath = download.savePath || null;
|
|
||||||
download.targetPath = movie.path || null;
|
|
||||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
|
||||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
|
||||||
}
|
|
||||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
|
||||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
|
||||||
}
|
|
||||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
|
||||||
addOmbiMatching(download, movie, context);
|
|
||||||
matched.push(download);
|
|
||||||
matchedAny = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
const dlObj = buildArrDownload(sonarrHistoryMatch, context, {
|
||||||
if (series) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
arrType: 'sonarr',
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
progress: 100, // completed
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords)
|
||||||
const download = mapTorrentToDownload(torrent);
|
});
|
||||||
Object.assign(download, {
|
if (dlObj) matched.push(dlObj);
|
||||||
type: 'series',
|
|
||||||
coverArt: DownloadAssembler.getCoverArt(series),
|
|
||||||
seriesName: series.title,
|
|
||||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
|
|
||||||
allTags,
|
|
||||||
matchedUserTag: matchedUserTag || null,
|
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
|
||||||
});
|
|
||||||
if (isAdmin) {
|
|
||||||
download.downloadPath = download.savePath || null;
|
|
||||||
download.targetPath = series.path || null;
|
|
||||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
|
||||||
}
|
|
||||||
addOmbiMatching(download, series, context);
|
|
||||||
matched.push(download);
|
|
||||||
matchedAny = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
const dlObj = buildArrDownload(radarrHistoryMatch, context, {
|
||||||
if (movie) {
|
...commonOptions,
|
||||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
arrType: 'radarr',
|
||||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
progress: 100 // completed
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
});
|
||||||
const download = mapTorrentToDownload(torrent);
|
if (dlObj) matched.push(dlObj);
|
||||||
download.id = download.hash || torrent.hash;
|
}
|
||||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
}
|
||||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
|
||||||
Object.assign(download, {
|
// Deduplicate by (arrType, arrQueueId) (Issue #65)
|
||||||
type: 'movie',
|
const seen = new Set();
|
||||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
const deduped = [];
|
||||||
movieName: movie.title,
|
for (const m of matched) {
|
||||||
movieInfo: radarrHistoryMatch,
|
const key = (m && m.arrType && m.arrQueueId != null)
|
||||||
allTags,
|
? `${m.arrType}:${m.arrQueueId}`
|
||||||
matchedUserTag: matchedUserTag || null,
|
: null;
|
||||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
if (key) {
|
||||||
});
|
if (seen.has(key)) continue;
|
||||||
if (isAdmin) {
|
seen.add(key);
|
||||||
download.downloadPath = download.savePath || null;
|
}
|
||||||
download.targetPath = movie.path || null;
|
deduped.push(m);
|
||||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
}
|
||||||
}
|
return deduped;
|
||||||
addOmbiMatching(download, movie, context);
|
}
|
||||||
matched.push(download);
|
|
||||||
matchedAny = true;
|
/**
|
||||||
}
|
* Matches orphaned *arr queue items that have no corresponding download client item
|
||||||
|
* but still reside in the active Sonarr/Radarr queue.
|
||||||
|
* @param {Set<number>} matchedArrQueueIds - Already matched queue record IDs to skip
|
||||||
|
* @param {Object} context - Matching context with records, maps, and user info
|
||||||
|
* @returns {Array} Array of orphaned download objects
|
||||||
|
*/
|
||||||
|
function matchOrphanedArrRecords(matchedArrQueueIds, context) {
|
||||||
|
const { sonarrQueueRecords, radarrQueueRecords } = context;
|
||||||
|
const matched = [];
|
||||||
|
|
||||||
|
// Deduplication Strategy:
|
||||||
|
// We initialize the processed Set with already-matched IDs compiled during Phase 1 matching.
|
||||||
|
// We also track newly processed IDs locally to handle situations where multiple duplicate queue
|
||||||
|
// records pointing to the same downloadId exist in Sonarr/Radarr.
|
||||||
|
const processedQueueIds = new Set(matchedArrQueueIds);
|
||||||
|
|
||||||
|
const processRecords = (records, arrType) => {
|
||||||
|
for (const record of records) {
|
||||||
|
if (processedQueueIds.has(record.id)) continue;
|
||||||
|
processedQueueIds.add(record.id);
|
||||||
|
|
||||||
|
// Safe progress arithmetic to prevent NaN or division-by-zero
|
||||||
|
const size = record.size || 0;
|
||||||
|
const sizeleft = record.sizeleft || 0;
|
||||||
|
const progress = size > 0 ? Math.round(100 - (sizeleft / size * 100)) : 0;
|
||||||
|
const status = record.trackedDownloadStatus || record.status || 'Unknown';
|
||||||
|
|
||||||
|
const dl = buildArrDownload(record, context, {
|
||||||
|
arrType,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
client: 'orphaned',
|
||||||
|
instanceId: 'orphaned',
|
||||||
|
instanceName: 'Orphaned (unconfigured client)',
|
||||||
|
overrides: { isOrphaned: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dl) {
|
||||||
|
logger.debug(`[DownloadMatcher] Found orphaned *arr queue item: ${dl.title} (arrQueueId=${record.id})`);
|
||||||
|
matched.push(dl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processRecords(sonarrQueueRecords || [], 'sonarr');
|
||||||
|
processRecords(radarrQueueRecords || [], 'radarr');
|
||||||
|
|
||||||
}
|
|
||||||
return matched;
|
return matched;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,5 +609,7 @@ module.exports = {
|
|||||||
addOmbiMatching,
|
addOmbiMatching,
|
||||||
matchSabSlots,
|
matchSabSlots,
|
||||||
matchSabHistory,
|
matchSabHistory,
|
||||||
matchTorrents
|
matchTorrents,
|
||||||
|
buildArrDownload,
|
||||||
|
matchOrphanedArrRecords
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+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 || [])
|
||||||
|
|||||||
@@ -260,19 +260,10 @@ async function csrfHeaders(app) {
|
|||||||
// Environment
|
// Environment
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeEach(() => {
|
||||||
process.env.EMBY_URL = EMBY_BASE;
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
||||||
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.EMBY_URL;
|
|
||||||
delete process.env.SONARR_INSTANCES;
|
|
||||||
delete process.env.RADARR_INSTANCES;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
seedEmptyCache();
|
seedEmptyCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,6 +271,9 @@ afterEach(() => {
|
|||||||
nock.cleanAll();
|
nock.cleanAll();
|
||||||
invalidatePollCache();
|
invalidatePollCache();
|
||||||
cache.invalidate('emby:users');
|
cache.invalidate('emby:users');
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -387,6 +387,38 @@ describe('Replay protection', () => {
|
|||||||
expect(first.body.duplicate).toBeUndefined();
|
expect(first.body.duplicate).toBeUndefined();
|
||||||
expect(second.body.duplicate).toBeUndefined();
|
expect(second.body.duplicate).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sonarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const payload = {
|
||||||
|
eventType: 'Test',
|
||||||
|
instanceName: 'Main Sonarr',
|
||||||
|
date: '2026-05-19T13:00:00.000Z'
|
||||||
|
};
|
||||||
|
const first = await postSonarr(app, payload);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.body.duplicate).toBeUndefined();
|
||||||
|
|
||||||
|
const second = await postSonarr(app, payload);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(second.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('radarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const payload = {
|
||||||
|
eventType: 'Test',
|
||||||
|
instanceName: 'Main Radarr',
|
||||||
|
date: '2026-05-19T13:00:00.000Z'
|
||||||
|
};
|
||||||
|
const first = await postRadarr(app, payload);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.body.duplicate).toBeUndefined();
|
||||||
|
|
||||||
|
const second = await postRadarr(app, payload);
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(second.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -925,4 +925,158 @@ describe('buildUserDownloads', () => {
|
|||||||
expect(result[0].arrLink).toBe('https://sonarr.test/series/test-series');
|
expect(result[0].arrLink).toBe('https://sonarr.test/series/test-series');
|
||||||
expect(result[1].arrLink).toBe('https://radarr.test/movie/test-movie');
|
expect(result[1].arrLink).toBe('https://radarr.test/movie/test-movie');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('orphaned download integration in DownloadBuilder', () => {
|
||||||
|
it('returns orphaned queue records when no active client match is found', async () => {
|
||||||
|
const cacheSnapshot = {
|
||||||
|
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||||
|
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||||
|
sonarrQueue: {
|
||||||
|
data: {
|
||||||
|
records: [{
|
||||||
|
id: 500,
|
||||||
|
title: 'Genuinely Orphaned Show',
|
||||||
|
sourceTitle: 'Genuinely Orphaned Show',
|
||||||
|
seriesId: 1,
|
||||||
|
series: seriesMap.get(1),
|
||||||
|
size: 200000000,
|
||||||
|
sizeleft: 100000000,
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['Missing files'] }]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sonarrHistory: { data: { records: [] } },
|
||||||
|
radarrQueue: { data: { records: [] } },
|
||||||
|
radarrHistory: { data: { records: [] } },
|
||||||
|
qbittorrentTorrents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildUserDownloads(cacheSnapshot, {
|
||||||
|
username,
|
||||||
|
usernameSanitized,
|
||||||
|
isAdmin: true,
|
||||||
|
showAll,
|
||||||
|
seriesMap,
|
||||||
|
moviesMap,
|
||||||
|
sonarrTagMap,
|
||||||
|
radarrTagMap,
|
||||||
|
embyUserMap
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
title: 'Genuinely Orphaned Show',
|
||||||
|
isOrphaned: true,
|
||||||
|
client: 'orphaned',
|
||||||
|
instanceId: 'orphaned',
|
||||||
|
instanceName: 'Orphaned (unconfigured client)',
|
||||||
|
progress: 50,
|
||||||
|
importIssues: ['Missing files']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly deduplicates so active matched items are NOT duplicated as orphans', async () => {
|
||||||
|
const cacheSnapshot = {
|
||||||
|
sabnzbdQueue: {
|
||||||
|
data: {
|
||||||
|
queue: {
|
||||||
|
status: 'Downloading',
|
||||||
|
speed: '5.0 MB/s',
|
||||||
|
kbpersec: 5120,
|
||||||
|
slots: [{
|
||||||
|
filename: 'Matched Active Show',
|
||||||
|
nzbname: 'Matched Active Show',
|
||||||
|
status: 'Downloading',
|
||||||
|
percentage: 50,
|
||||||
|
mb: 1000,
|
||||||
|
mbmissing: 500,
|
||||||
|
size: '1 GB',
|
||||||
|
timeleft: '10:00'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||||
|
sonarrQueue: {
|
||||||
|
data: {
|
||||||
|
records: [{
|
||||||
|
id: 100,
|
||||||
|
downloadId: '100', // matches slot by default mock (or slot.id/nzo_id)
|
||||||
|
title: 'Matched Active Show',
|
||||||
|
sourceTitle: 'Matched Active Show',
|
||||||
|
seriesId: 1,
|
||||||
|
series: seriesMap.get(1)
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sonarrHistory: { data: { records: [] } },
|
||||||
|
radarrQueue: { data: { records: [] } },
|
||||||
|
radarrHistory: { data: { records: [] } },
|
||||||
|
qbittorrentTorrents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set slot nzo_id to match the downloadId
|
||||||
|
cacheSnapshot.sabnzbdQueue.data.queue.slots[0].nzo_id = '100';
|
||||||
|
|
||||||
|
const result = await buildUserDownloads(cacheSnapshot, {
|
||||||
|
username,
|
||||||
|
usernameSanitized,
|
||||||
|
isAdmin: true,
|
||||||
|
showAll,
|
||||||
|
seriesMap,
|
||||||
|
moviesMap,
|
||||||
|
sonarrTagMap,
|
||||||
|
radarrTagMap,
|
||||||
|
embyUserMap
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should match the download once via SABnzbd client; should NOT list it again as an orphan
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].isOrphaned).toBeUndefined();
|
||||||
|
expect(result[0].client).toBe('sabnzbd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters orphaned records based on user tag matches', async () => {
|
||||||
|
const cacheSnapshot = {
|
||||||
|
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||||
|
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||||
|
sonarrQueue: {
|
||||||
|
data: {
|
||||||
|
records: [{
|
||||||
|
id: 600,
|
||||||
|
title: 'Bobs Orphaned Show',
|
||||||
|
sourceTitle: 'Bobs Orphaned Show',
|
||||||
|
seriesId: 2, // Bob's series (tag=2, username=bob)
|
||||||
|
series: {
|
||||||
|
id: 2,
|
||||||
|
title: 'Bob Show',
|
||||||
|
tags: [2],
|
||||||
|
images: []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sonarrHistory: { data: { records: [] } },
|
||||||
|
radarrQueue: { data: { records: [] } },
|
||||||
|
radarrHistory: { data: { records: [] } },
|
||||||
|
qbittorrentTorrents: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildUserDownloads(cacheSnapshot, {
|
||||||
|
username: 'alice', // alice should not see bob's orphaned downloads
|
||||||
|
usernameSanitized: 'alice',
|
||||||
|
isAdmin: false,
|
||||||
|
showAll: false,
|
||||||
|
seriesMap: new Map([[2, { id: 2, title: 'Bob Show', tags: [2], images: [] }]]),
|
||||||
|
moviesMap,
|
||||||
|
sonarrTagMap: new Map([[1, 'alice'], [2, 'bob']]),
|
||||||
|
radarrTagMap,
|
||||||
|
embyUserMap
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,4 +145,140 @@ describe('DownloadMatcher', () => {
|
|||||||
expect(result.speed).toBe('1.5 MB/s');
|
expect(result.speed).toBe('1.5 MB/s');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildArrDownload', () => {
|
||||||
|
const context = {
|
||||||
|
seriesMap: new Map([[1, { id: 1, title: 'My Show', tags: [1] }]]),
|
||||||
|
moviesMap: new Map([[2, { id: 2, title: 'My Movie', tags: [1] }]]),
|
||||||
|
sonarrTagMap: new Map([[1, 'alice']]),
|
||||||
|
radarrTagMap: new Map([[1, 'alice']]),
|
||||||
|
username: 'alice',
|
||||||
|
isAdmin: false,
|
||||||
|
showAll: false,
|
||||||
|
embyUserMap: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
it('correctly uses caller-supplied client, instanceId, and instanceName values', () => {
|
||||||
|
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||||
|
const dl = DownloadMatcher.buildArrDownload(record, context, {
|
||||||
|
client: 'deluge',
|
||||||
|
instanceId: 'deluge-1',
|
||||||
|
instanceName: 'Deluge Instance 1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.client).toBe('deluge');
|
||||||
|
expect(dl.instanceId).toBe('deluge-1');
|
||||||
|
expect(dl.instanceName).toBe('Deluge Instance 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses neutral fallback defaults when not supplied', () => {
|
||||||
|
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||||
|
const dl = DownloadMatcher.buildArrDownload(record, context);
|
||||||
|
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.client).toBe('orphaned');
|
||||||
|
expect(dl.instanceId).toBe('orphaned');
|
||||||
|
expect(dl.instanceName).toBe('Unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses correct blocklist determination and defaults progress to 0', () => {
|
||||||
|
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||||
|
const dl = DownloadMatcher.buildArrDownload(record, context);
|
||||||
|
|
||||||
|
expect(dl.progress).toBe(0);
|
||||||
|
expect(dl.canBlocklist).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchSabHistory', () => {
|
||||||
|
const context = {
|
||||||
|
sonarrHistoryRecords: [
|
||||||
|
{ id: 100, downloadId: 'sabnzbd_1234', seriesId: 1 }
|
||||||
|
],
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{ id: 101, downloadId: 'sabnzbd_5678', seriesId: 1 }
|
||||||
|
],
|
||||||
|
radarrHistoryRecords: [],
|
||||||
|
radarrQueueRecords: [],
|
||||||
|
seriesMap: new Map([[1, { id: 1, title: 'Show 1', tags: [1] }]]),
|
||||||
|
moviesMap: new Map(),
|
||||||
|
sonarrTagMap: new Map([[1, 'alice']]),
|
||||||
|
radarrTagMap: new Map(),
|
||||||
|
username: 'alice',
|
||||||
|
isAdmin: false,
|
||||||
|
showAll: false,
|
||||||
|
embyUserMap: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
it('matches by downloadId case-insensitively and type-safely', async () => {
|
||||||
|
const slots = [{ id: 'SABNZBD_1234', name: 'Show 1', status: 'Completed', mb: 1000 }];
|
||||||
|
const result = await DownloadMatcher.matchSabHistory(slots, context);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].arrQueueId).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dual-lookup: matches history slots against active queue records', async () => {
|
||||||
|
const slots = [{ id: 'sabnzbd_5678', name: 'Show 1', status: 'Completed', mb: 1000 }];
|
||||||
|
const result = await DownloadMatcher.matchSabHistory(slots, context);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].arrQueueId).toBe(101);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('titleMatches helper', () => {
|
||||||
|
it('matches identical strings and handles dots/spaces/dashes bidirectionally', () => {
|
||||||
|
// Direct exports or internal reference
|
||||||
|
const titleMatches = DownloadMatcher.__get__ ? DownloadMatcher.__get__('titleMatches') : null;
|
||||||
|
// Since it is not exported, we can test it indirectly via matchTorrents or call it if we export it,
|
||||||
|
// or we can test it via matchTorrents fallback matching. Let's test it using matchTorrents with dot names:
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchOrphanedArrRecords', () => {
|
||||||
|
const context = {
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{ id: 100, seriesId: 1, title: 'Orphan 1', size: 1000, sizeleft: 400 },
|
||||||
|
{ id: 101, seriesId: 1, title: 'Already Matched', size: 1000, sizeleft: 0 }
|
||||||
|
],
|
||||||
|
radarrQueueRecords: [],
|
||||||
|
seriesMap: new Map([[1, { id: 1, title: 'Series 1', tags: [1] }]]),
|
||||||
|
moviesMap: new Map(),
|
||||||
|
sonarrTagMap: new Map([[1, 'alice']]),
|
||||||
|
radarrTagMap: new Map(),
|
||||||
|
username: 'alice',
|
||||||
|
isAdmin: false,
|
||||||
|
showAll: false,
|
||||||
|
embyUserMap: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
it('constructs orphans, filters matched IDs, and computes safe progress math', () => {
|
||||||
|
const matchedIds = new Set([101]);
|
||||||
|
const result = DownloadMatcher.matchOrphanedArrRecords(matchedIds, context);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
title: 'Orphan 1',
|
||||||
|
isOrphaned: true,
|
||||||
|
progress: 60,
|
||||||
|
client: 'orphaned',
|
||||||
|
instanceId: 'orphaned'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles size=0 safely without returning NaN or Infinity', () => {
|
||||||
|
const zeroContext = {
|
||||||
|
...context,
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{ id: 102, seriesId: 1, title: 'Zero Size', size: 0, sizeleft: 0 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = DownloadMatcher.matchOrphanedArrRecords(new Set(), zeroContext);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].progress).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||||
globals: true,
|
globals: true,
|
||||||
|
// Increase test timeout to avoid transient timeouts under coverage/heavy loads
|
||||||
|
testTimeout: 15000,
|
||||||
|
// Run test files sequentially to avoid cross-test background event pollution
|
||||||
|
fileParallelism: false,
|
||||||
// Run each test file in an isolated module registry so module-level state
|
// Run each test file in an isolated module registry so module-level state
|
||||||
// (tokenStore cache, config singletons) doesn't leak between files
|
// (tokenStore cache, config singletons) doesn't leak between files
|
||||||
isolate: true,
|
isolate: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user