Compare commits

..

19 Commits

Author SHA1 Message Date
gronod 7f7a91f056 merge branch 'develop' into 'main' - Release v1.7.33
CI / Tests & coverage (push) Successful in 2m53s
Create Release / release (push) Successful in 17s
CI / Security audit (push) Successful in 3m8s
Build and Push Docker Image / build (push) Successful in 1m11s
CI / Swagger Validation & Coverage (push) Successful in 2m23s
2026-05-28 17:42:54 +01:00
gronod 879aee8eea merge branch 'develop' into 'main' - Release v1.7.32 (include markdownlint fix)
Create Release / release (push) Successful in 30s
Build and Push Docker Image / build (push) Successful in 1m24s
CI / Security audit (push) Successful in 2m1s
CI / Swagger Validation & Coverage (push) Successful in 2m16s
CI / Tests & coverage (push) Successful in 2m37s
2026-05-28 16:28:23 +01:00
gronod f8f693e32a merge branch 'develop' into 'main' - Release v1.7.32
Create Release / release (push) Successful in 24s
CI / Security audit (push) Successful in 2m48s
Build and Push Docker Image / build (push) Successful in 2m14s
CI / Swagger Validation & Coverage (push) Successful in 2m54s
CI / Tests & coverage (push) Has been cancelled
2026-05-28 16:25:07 +01:00
gronod c18f5bd26e merge branch 'develop' into 'main' - Release v1.7.31
CI / Tests & coverage (push) Successful in 2m53s
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m18s
CI / Swagger Validation & Coverage (push) Successful in 1m40s
2026-05-28 08:12:26 +01:00
gronod e726fbe33f merge branch 'develop' into 'main' - Release v1.7.30
Create Release / release (push) Successful in 42s
CI / Security audit (push) Successful in 1m36s
CI / Swagger Validation & Coverage (push) Successful in 3m20s
CI / Tests & coverage (push) Successful in 3m53s
2026-05-28 08:03:41 +01:00
gronod 4107bdf611 merge branch 'develop' into 'main' - Fix CHANGELOG formatting for Release v1.7.30
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m55s
CI / Swagger Validation & Coverage (push) Successful in 1m24s
Create Release / release (push) Successful in 14s
2026-05-28 07:02:57 +01:00
gronod 52806d00dc merge branch 'develop' into 'main' - Release v1.7.30
CI / Tests & coverage (push) Successful in 2m9s
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m52s
CI / Swagger Validation & Coverage (push) Successful in 2m4s
2026-05-28 01:39:13 +01:00
gronod dcb77dd27f merge branch 'develop' into 'main' - Release v1.7.29
CI / Security audit (push) Successful in 2m47s
Create Release / release (push) Successful in 40s
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 3m32s
2026-05-27 23:51:12 +01:00
gronod 6c3ffb9b77 merge branch 'develop' into 'main' - Release v1.7.28
Create Release / release (push) Successful in 22s
CI / Swagger Validation & Coverage (push) Successful in 1m52s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m1s
CI / Tests & coverage (push) Successful in 3m38s
2026-05-27 23:26:11 +01:00
gronod 7226404221 merge branch 'develop' into 'main' - Release v1.7.27
Create Release / release (push) Successful in 37s
CI / Swagger Validation & Coverage (push) Successful in 2m5s
CI / Security audit (push) Successful in 2m59s
Build and Push Docker Image / build (push) Successful in 1m39s
CI / Tests & coverage (push) Successful in 3m48s
2026-05-27 23:11:37 +01:00
gronod 0eaa54cf4a merge branch 'develop' into 'main' - Release v1.7.26
Create Release / release (push) Successful in 8s
CI / Security audit (push) Successful in 3m0s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
Build and Push Docker Image / build (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 3m37s
2026-05-27 22:50:31 +01:00
gronod fd0dc7528d merge branch 'develop' into 'main' - Release v1.7.25
Create Release / release (push) Successful in 23s
CI / Security audit (push) Successful in 1m49s
Build and Push Docker Image / build (push) Successful in 1m50s
CI / Swagger Validation & Coverage (push) Successful in 2m18s
CI / Tests & coverage (push) Successful in 2m30s
2026-05-27 21:43:45 +01:00
gronod c4e584cc3b merge branch 'develop' into 'main' - Release v1.7.24
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 57s
CI / Security audit (push) Successful in 3m7s
CI / Swagger Validation & Coverage (push) Successful in 1m49s
CI / Tests & coverage (push) Successful in 3m43s
2026-05-27 19:35:19 +01:00
gronod 610632c4f0 merge branch 'develop' into 'main' - Release v1.7.23
Build and Push Docker Image / build (push) Successful in 1m4s
Create Release / release (push) Successful in 53s
CI / Security audit (push) Successful in 1m42s
CI / Swagger Validation & Coverage (push) Successful in 1m42s
CI / Tests & coverage (push) Successful in 2m21s
2026-05-27 19:16:23 +01:00
gronod 1535a5725a merge branch 'develop' into 'main' - Release v1.7.22
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
CI / Security audit (push) Successful in 2m26s
CI / Tests & coverage (push) Successful in 2m55s
2026-05-27 17:44:51 +01:00
gronod 8fb00843ef merge branch 'develop' into 'main' - Release v1.7.21
CI / Security audit (push) Successful in 2m50s
CI / Swagger Validation & Coverage (push) Successful in 3m23s
CI / Tests & coverage (push) Successful in 3m30s
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 11s
2026-05-26 15:20:40 +01:00
gronod 6f6aa5b967 merge branch 'develop' into 'main' - Release v1.7.20
Build and Push Docker Image / build (push) Successful in 1m13s
Create Release / release (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m36s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
CI / Tests & coverage (push) Successful in 3m15s
2026-05-26 13:43:33 +01:00
gronod fb68bddedb merge branch 'develop' into 'main' - Update Release v1.7.19 to ignore scratch directory
Build and Push Docker Image / build (push) Successful in 1m8s
Create Release / release (push) Successful in 36s
CI / Tests & coverage (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 1m50s
2026-05-25 08:33:16 +01:00
gronod 7d7304637c merge branch 'develop' into 'main' - Release v1.7.19
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 2m19s
CI / Security audit (push) Successful in 2m14s
CI / Tests & coverage (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m24s
2026-05-25 08:28:28 +01:00
19 changed files with 515 additions and 990 deletions
-42
View File
@@ -4,48 +4,6 @@ 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.38] - 2026-05-29
### Fixed
- **SABnzbd History Legacy Slot Name Compatibility (Issue #74)** — Hardened SABnzbd active-download and history slot title matching in `DownloadMatcher.js` to support all slot name property variations (`filename`, `nzbname`, `name`, `nzb_name`). This ensures history matching succeeds against cached/legacy data schemas where the name is stored solely under the `filename` property, preventing completed downloads awaiting import from incorrectly displaying as `"Unknown"` client cards. Added unit tests for legacy `filename` slot matching compatibility.
## [1.7.37] - 2026-05-29
### Fixed
- **SABnzbd History Matching Symmetry (Issue #74)** — Consolidated SABnzbd active-download matching algorithms in `DownloadMatcher.js` by introducing a unified, type-safe internal helper `findSabMatch(sabDownloadId, nzbName, context, caller)`. Refactored `matchSabSlots` and `matchSabHistory` to route entirely through `findSabMatch`. This resolves a bug where completed SABnzbd downloads awaiting manual import in Sonarr or Radarr queues were incorrectly flagged as "unknown" client/"Orphaned (unconfigured client)". Added detailed unit tests to safeguard this behavior.
## [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 ## [1.7.33] - 2026-05-28
### Added ### Added
+5 -31
View File
@@ -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 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. 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).
## API Endpoints ## API Endpoints
@@ -474,36 +474,10 @@ 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/queue` — SABnzbd queue - `GET /api/sabnzbd/*` — SABnzbd API proxy
- `GET /api/sabnzbd/history` — SABnzbd history - `GET /api/sonarr/*` — Sonarr API proxy
- `GET /api/sonarr/queue` — Sonarr queue - `GET /api/radarr/*` — Radarr API proxy
- `GET /api/sonarr/history` — Sonarr history - `GET /api/emby/*` — Emby API proxy
- `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
+2 -7
View File
@@ -35,17 +35,12 @@ 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.isOrphaned clientLogo.title = download.instanceName || download.client;
? "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');
@@ -308,7 +303,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}${download.isOrphaned ? ' orphaned' : ''}`; card.className = `download-card ${download.type}`;
card.dataset.id = download.title; card.dataset.id = download.title;
// Cover art // Cover art
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.38", "version": "1.7.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.38", "version": "1.7.33",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.38", "version": "1.7.33",
"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
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 409 B

+6 -18
View File
@@ -170,12 +170,8 @@
<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 tab-header"> <div class="downloads-header">
<div class="tab-header-title"> <div class="downloads-controls">
<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">
@@ -204,12 +200,8 @@
<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 tab-header"> <div class="requests-header">
<div class="tab-header-title"> <div class="requests-controls">
<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>
@@ -294,12 +286,8 @@
<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 tab-header"> <div class="history-header">
<div class="tab-header-title"> <div class="history-controls">
<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>
+32 -64
View File
@@ -689,61 +689,15 @@ body {
padding: 0; padding: 0;
} }
/* Unified Tab Headers (Issue #72) */ /* Downloads header and controls */
.tab-header { .downloads-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;
@@ -944,7 +898,11 @@ body {
/* ===== Request Filters ===== */ /* ===== Request Filters ===== */
.requests-header { .requests-header {
/* Inherits from .tab-header */ display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
} }
.requests-controls { .requests-controls {
@@ -1118,7 +1076,18 @@ body {
} }
.history-header { .history-header {
/* Inherits from .tab-header */ display: flex;
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 {
@@ -1165,7 +1134,7 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 0.85rem; font-size: 0.82rem;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -2268,7 +2237,17 @@ body {
} }
.requests-header { .requests-header {
/* Inherits from .tab-header */ display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.requests-header h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
} }
.no-requests { .no-requests {
@@ -2419,14 +2398,3 @@ 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
View File
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.38" * example: "1.7.33"
* x-code-samples: * x-code-samples:
* - lang: curl * - lang: curl
* label: cURL * label: cURL
+3 -3
View File
@@ -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.38 version: 1.7.33
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: Selective Sonarr API proxy (specific endpoints only) description: Sonarr API proxy
- name: Radarr - name: Radarr
description: Selective Radarr API proxy (specific endpoints only) description: Radarr API proxy
- name: SABnzbd - name: SABnzbd
description: SABnzbd API proxy description: SABnzbd API proxy
- name: Emby - name: Emby
-10
View File
@@ -528,16 +528,6 @@ 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
+2 -8
View File
@@ -476,10 +476,7 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
// Content-aware replay key components (Issue #62) // Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.series?.id || null; const contentId = req.body.downloadId || req.body.series?.id || null;
// Skip replay protection for Test events if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
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'})`); 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 });
} }
@@ -640,10 +637,7 @@ router.post('/radarr', webhookLimiter, (req, res) => {
// Content-aware replay key components (Issue #62) // Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.movie?.id || null; const contentId = req.body.downloadId || req.body.movie?.id || null;
// Skip replay protection for Test events if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
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'})`); 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 });
} }
-14
View File
@@ -72,7 +72,6 @@ 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);
@@ -81,7 +80,6 @@ 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);
} }
} }
} }
@@ -93,24 +91,12 @@ 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);
+451 -400
View File
@@ -9,199 +9,6 @@
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;
}
/**
* Internal helper: Finds the best matching Sonarr or Radarr record for a SABnzbd slot.
* Performs robust case-insensitive downloadId matching (queue history),
* then bidirectional title fallback (queue history).
* This eliminates all duplication and asymmetry between matchSabSlots and matchSabHistory.
*
* @param {string|null} sabDownloadId
* @param {string} nzbName
* @param {Object} context
* @param {string} caller - e.g. 'matchSabHistory' or 'matchSabSlots'
* @returns {{ sonarrMatch: Object|null, radarrMatch: Object|null }}
*/
function findSabMatch(sabDownloadId, nzbName, context, caller = 'DownloadMatcher') {
const {
sonarrQueueRecords = [],
sonarrHistoryRecords = [],
radarrQueueRecords = [],
radarrHistoryRecords = []
} = context;
const findBest = (queueRecords, historyRecords) => {
// 1. Robust ID match (queue first)
let match = sabDownloadId
? queueRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
})
: null;
if (!match && sabDownloadId) {
match = historyRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
});
}
// 2. Title fallback (queue first, then history)
if (!match && nzbName) {
match = queueRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
if (!match && nzbName) {
match = historyRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
return match || null;
};
return {
sonarrMatch: findBest(sonarrQueueRecords, sonarrHistoryRecords),
radarrMatch: findBest(radarrQueueRecords, radarrHistoryRecords)
};
}
/**
* 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.
@@ -283,107 +90,310 @@ 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 = [];
for (const slot of slots) { for (const slot of slots) {
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name; const nzbName = slot.filename || slot.nzbname;
if (!nzbName) continue; if (!nzbName) continue;
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)
const sabDownloadId = slot.nzo_id || slot.id; const sabDownloadId = slot.nzo_id || slot.id;
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabSlots'); let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
// Progress calculation // Also check HISTORY by downloadId
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0; if (!sonarrMatch && sabDownloadId) {
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null) sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
? parseFloat(slot.mbleft || slot.mbmissing) }
: 0; if (!radarrMatch && sabDownloadId) {
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0; radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
}
const commonOptions = { // Fallback: Check by title matching
title: nzbName, if (!sonarrMatch) {
status: slotState.status, sonarrMatch = sonarrQueueRecords.find(r => {
progress: Math.round(progress), const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
mb: slot.mb, return rTitle && (
size: Math.round(slot.mb * 1024 * 1024), rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
client: 'sabnzbd', rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
instanceId: slot.instanceId || 'sabnzbd-default', );
instanceName: slot.instanceName || 'SABnzbd', });
downloadPath: slot.storage || null, }
overrides: { if (!radarrMatch) {
mbmissing: slot.mbleft, radarrMatch = radarrQueueRecords.find(r => {
speed: Math.round((slot.kbpersec || 0) * 1024), const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
eta: slot.timeleft return rTitle && (
} rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
}; rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
// Also check HISTORY (completed downloads) if no queue match
if (!sonarrMatch) {
sonarrMatch = sonarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrHistoryRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (sonarrMatch && sonarrMatch.seriesId) { if (sonarrMatch && sonarrMatch.seriesId) {
const dlObj = buildArrDownload(sonarrMatch, context, { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
...commonOptions, if (series) {
arrType: 'sonarr', const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords) const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, 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: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
mbmissing: slot.mbleft,
size: Math.round(slot.mb * 1024 * 1024),
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
// 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 (radarrMatch && radarrMatch.movieId) { if (radarrMatch && radarrMatch.movieId) {
const dlObj = buildArrDownload(radarrMatch, context, { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
...commonOptions, if (movie) {
arrType: 'radarr' const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
}); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (dlObj) matched.push(dlObj); if (showAll ? allTags.length > 0 : !!matchedUserTag) {
// Calculate progress from SABnzbd slot data
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
? parseFloat(slot.mbleft || slot.mbmissing)
: 0;
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
const dlObj = {
type: 'movie',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(movie),
status: slotState.status,
progress: Math.round(progress),
mb: slot.mb,
mbmissing: slot.mbleft,
size: Math.round(slot.mb * 1024 * 1024),
speed: Math.round((slot.kbpersec || 0) * 1024),
eta: slot.timeleft,
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
// 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;
} }
/**
* Matches SABnzbd history slots to Sonarr/Radarr activity using title matching.
* @param {Array} slots - SABnzbd history slots
* @param {Object} context - Matching context with records, maps, and user info
* @returns {Array} Array of matched download objects
*/
async function matchSabHistory(slots, context) { async function matchSabHistory(slots, context) {
const {
sonarrHistoryRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
ombiRetriever,
ombiBaseUrl
} = context;
const matched = []; const matched = [];
for (const slot of slots) { for (const slot of slots) {
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name; const nzbName = slot.name || slot.nzb_name || slot.nzbname;
if (!nzbName) continue; if (!nzbName) continue;
const nzbNameLower = nzbName.toLowerCase();
const sabDownloadId = slot.nzo_id || slot.id; const sonarrMatch = sonarrHistoryRecords.find(r => {
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory'); const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
const commonOptions = { });
title: nzbName,
status: slot.status || 'Completed',
progress: 100,
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) { if (sonarrMatch && sonarrMatch.seriesId) {
const dlObj = buildArrDownload(sonarrMatch, context, { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
...commonOptions, if (series) {
arrType: 'sonarr', const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
episodes: DownloadAssembler.gatherEpisodes(nzbName.toLowerCase(), context.sonarrHistoryRecords || []) const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
}); if (showAll ? allTags.length > 0 : !!matchedUserTag) {
if (dlObj) matched.push(dlObj); const dlObj = {
type: 'series',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(series),
status: slot.status,
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
}
addOmbiMatching(dlObj, series, context);
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 dlObj = buildArrDownload(radarrMatch, context, { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
...commonOptions, if (movie) {
arrType: 'radarr' const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
}); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (dlObj) matched.push(dlObj); if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = {
type: 'movie',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(movie),
status: slot.status,
progress: 100, // History items are completed
mb: slot.mb,
size: Math.round((slot.mb || 0) * 1024 * 1024),
completedAt: slot.completed_time,
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
client: 'sabnzbd',
instanceId: slot.instanceId || 'sabnzbd-default',
instanceName: slot.instanceName || 'SABnzbd'
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
}
addOmbiMatching(dlObj, movie, context);
matched.push(dlObj);
}
}
} }
} }
return matched; return matched;
} }
@@ -398,7 +408,17 @@ 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 = [];
@@ -407,7 +427,12 @@ async function matchTorrents(torrents, context) {
if (!torrentName) continue; if (!torrentName) continue;
const torrentNameLower = torrentName.toLowerCase(); const torrentNameLower = torrentName.toLowerCase();
// Hash-first matching (Issue #65) let matchedAny = false;
// Hash-first matching (Issue #65): prefer matching by torrent hash against
// each *arr queue record's `downloadId`. `torrent.hash` covers qBittorrent
// and rTorrent; `torrent.hashString` covers Transmission. We fall back to
// existing title-substring matching only if no hash match was found.
const torrentHash = torrent?.hash || torrent?.hashString || null; const torrentHash = torrent?.hash || torrent?.hashString || null;
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null; const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
const matchesByHash = (r) => { const matchesByHash = (r) => {
@@ -417,104 +442,183 @@ async function matchTorrents(torrents, context) {
}; };
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null; let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null; if (!sonarrMatch) sonarrMatch = sonarrQueueRecords.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
// Fallback: Check by title matching return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
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
}
};
if (sonarrMatch && sonarrMatch.seriesId) { if (sonarrMatch && sonarrMatch.seriesId) {
const dlObj = buildArrDownload(sonarrMatch, context, { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
...commonOptions, if (series) {
arrType: 'sonarr', const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords) const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, 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: '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;
}
}
} }
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
if (!radarrMatch) 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 dlObj = buildArrDownload(radarrMatch, context, { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
...commonOptions, if (movie) {
arrType: 'radarr' const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
}); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (dlObj) matched.push(dlObj); if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent);
download.id = download.hash || torrent.hash;
download.progress = parseFloat(download.progress) || torrent.progress || 0;
download.speed = download.rawSpeed || torrent.dlspeed || 0;
Object.assign(download, {
type: 'movie',
coverArt: DownloadAssembler.getCoverArt(movie),
movieName: movie.title,
movieInfo: radarrMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) download.importIssues = issues;
// 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;
}
}
} }
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
if (!sonarrHistoryMatch) 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 dlObj = buildArrDownload(sonarrHistoryMatch, context, { const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
...commonOptions, if (series) {
arrType: 'sonarr', const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
progress: 100, // completed const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords) if (showAll ? allTags.length > 0 : !!matchedUserTag) {
}); const download = mapTorrentToDownload(torrent);
if (dlObj) matched.push(dlObj); Object.assign(download, {
type: 'series',
coverArt: DownloadAssembler.getCoverArt(series),
seriesName: series.title,
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = DownloadAssembler.getSonarrLink(series);
}
addOmbiMatching(download, series, context);
matched.push(download);
matchedAny = true;
continue;
}
}
} }
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
if (!radarrHistoryMatch) 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 dlObj = buildArrDownload(radarrHistoryMatch, context, { const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
...commonOptions, if (movie) {
arrType: 'radarr', const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
progress: 100 // completed 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: radarrHistoryMatch,
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
});
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = DownloadAssembler.getRadarrLink(movie);
}
addOmbiMatching(download, movie, context);
matched.push(download);
matchedAny = true;
}
}
} }
} }
// Deduplicate by (arrType, arrQueueId) (Issue #65) // Deduplicate by (arrType, arrQueueId) (Issue #65). When a single torrent
// (typically a season pack) matches N *arr queue records sharing one
// arrQueueId via downstream emission paths, only the first matched download
// is retained. Entries without an arrQueueId pass through unchanged.
const seen = new Set(); const seen = new Set();
const deduped = []; const deduped = [];
for (const m of matched) { for (const m of matched) {
@@ -530,57 +634,6 @@ async function matchTorrents(torrents, context) {
return deduped; return deduped;
} }
/**
* 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;
}
module.exports = { module.exports = {
buildSeriesMapFromRecords, buildSeriesMapFromRecords,
buildMoviesMapFromRecords, buildMoviesMapFromRecords,
@@ -588,7 +641,5 @@ module.exports = {
addOmbiMatching, addOmbiMatching,
matchSabSlots, matchSabSlots,
matchSabHistory, matchSabHistory,
matchTorrents, matchTorrents
buildArrDownload,
matchOrphanedArrRecords
}; };
+10 -4
View File
@@ -260,10 +260,19 @@ async function csrfHeaders(app) {
// Environment // Environment
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
beforeEach(() => { beforeAll(() => {
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();
}); });
@@ -271,9 +280,6 @@ 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;
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-32
View File
@@ -387,38 +387,6 @@ 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();
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-154
View File
@@ -925,158 +925,4 @@ 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([]);
});
});
}); });
-191
View File
@@ -145,195 +145,4 @@ 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);
});
it('falls back to title matching against Sonarr queue records when downloadId is absent/unmatched', async () => {
const testContext = {
...context,
sonarrHistoryRecords: [],
sonarrQueueRecords: [
{ id: 201, seriesId: 1, title: 'My.Cool.Show.S01E01.720p-Group' }
],
seriesMap: new Map([[1, { id: 1, title: 'My Cool Show', tags: [1] }]])
};
const slots = [{ id: null, name: 'My_Cool_Show_S01E01_720p-Group.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(201);
expect(result[0].arrType).toBe('sonarr');
});
it('falls back to title matching against Radarr queue records when downloadId is absent/unmatched', async () => {
const testContext = {
...context,
radarrHistoryRecords: [],
radarrQueueRecords: [
{ id: 301, movieId: 2, title: 'Awesome Movie 2026 1080p' }
],
moviesMap: new Map([[2, { id: 2, title: 'Awesome Movie', tags: [1] }]]),
radarrTagMap: new Map([[1, 'alice']])
};
const slots = [{ id: null, name: 'Awesome.Movie.2026.1080p.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(301);
expect(result[0].arrType).toBe('radarr');
});
it('matches when history slots only have the filename field (cached legacy format)', async () => {
const testContext = {
...context,
sonarrHistoryRecords: [],
sonarrQueueRecords: [
{ id: 201, seriesId: 1, title: 'My.Cool.Show.S01E01.720p-Group' }
],
seriesMap: new Map([[1, { id: 1, title: 'My Cool Show', tags: [1] }]])
};
const slots = [{ id: null, filename: 'My_Cool_Show_S01E01_720p-Group.nzb', status: 'Completed', mb: 1000 }];
const result = await DownloadMatcher.matchSabHistory(slots, testContext);
expect(result).toHaveLength(1);
expect(result[0].arrQueueId).toBe(201);
expect(result[0].arrType).toBe('sonarr');
});
});
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);
});
});
}); });
-4
View File
@@ -5,10 +5,6 @@ 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,