Compare commits
105 Commits
v1.7.5
...
release/1.7.38
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e49b76842 | |||
| bbe99c1358 | |||
| b2a837b173 | |||
| 7e1e2dd564 | |||
| b2aa4f23fa | |||
| 87387aaebe | |||
| 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 | |||
| c18f5bd26e | |||
| b4a9d7187b | |||
| 691d101e56 | |||
| e726fbe33f | |||
| 6f2901b08c | |||
| 4107bdf611 | |||
| a4af16064b | |||
| 52806d00dc | |||
| d6907f42d3 | |||
| aec04474be | |||
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 | |||
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 | |||
| 0eaa54cf4a | |||
| 865cf1f57a | |||
| ff5f50cc3a | |||
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 | |||
| 8fb00843ef | |||
| d2ac7731ca | |||
| 6f6aa5b967 | |||
| 5390bbf615 | |||
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c | |||
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 | |||
| d4ee3b8ef7 | |||
| 64c872423f | |||
| 86c67bcf29 | |||
| 9548eb41f5 | |||
| d1db3118f0 | |||
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e |
+10
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
|||||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||||
SOFARR_BASE_URL=https://your-sofarr-url
|
SOFARR_BASE_URL=https://your-sofarr-url
|
||||||
|
|
||||||
|
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
|
||||||
|
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
|
||||||
|
# Useful if those services reside in the same local network/docker container setup and
|
||||||
|
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
|
||||||
|
# Example: http://sofarr:3001 or http://192.168.1.50:3001
|
||||||
|
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
|
||||||
|
|
||||||
# --- Webhook Polling Optimization (Phase 5) ---
|
# --- Webhook Polling Optimization (Phase 5) ---
|
||||||
|
|
||||||
# Minutes of silence after which the poller falls back to a full poll
|
# Minutes of silence after which the poller falls back to a full poll
|
||||||
@@ -162,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
OMBI_URL=https://ombi.example.com
|
OMBI_URL=https://ombi.example.com
|
||||||
OMBI_API_KEY=your-ombi-api-key-here
|
OMBI_API_KEY=your-ombi-api-key-here
|
||||||
|
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
|
||||||
|
# to resolve the race condition where Ombi fires the webhook before committing to its database.
|
||||||
|
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOTES
|
# NOTES
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'release/**'
|
- 'release/**'
|
||||||
- 'develop*'
|
- 'develop*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -23,17 +27,28 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
# Gitea package registry tags
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
echo "Building release image tags: ${TAGS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Log into Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.i3omb.com
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
audit:
|
audit:
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ data/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
.agents/
|
.agents/
|
||||||
.windsurf/
|
.windsurf/
|
||||||
|
scratch/
|
||||||
+53
-4
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
|||||||
Both endpoints share identical processing logic:
|
Both endpoints share identical processing logic:
|
||||||
|
|
||||||
```
|
```
|
||||||
Sonarr/Radarr
|
Sonarr/Radarr/Ombi
|
||||||
POST /api/webhook/sonarr
|
POST /api/webhook/sonarr
|
||||||
Headers: X-Sofarr-Webhook-Secret: <secret>
|
Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
|
||||||
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
||||||
"date": "2026-05-19T10:00:00.000Z", … }
|
"date": "2026-05-19T10:00:00.000Z", … }
|
||||||
│
|
│
|
||||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||||
|
(Checks header or query param)
|
||||||
│ ok
|
│ ok
|
||||||
▼
|
▼
|
||||||
validatePayload() ──fail──► 400 Bad Request
|
validatePayload() ──fail──► 400 Bad Request
|
||||||
@@ -994,6 +995,42 @@ For AI agents and automated tooling, every endpoint includes:
|
|||||||
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||||
- Runs the coverage test suite on every push
|
- Runs the coverage test suite on every push
|
||||||
|
|
||||||
|
### 7.5 Real-Time Debug Log Streaming Subsystem
|
||||||
|
|
||||||
|
sofarr provides a togglable, real-time log capturing and streaming engine allowing developer-administrators to view server standard output stream activity and browser console log activity for easy debugging in production.
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Browser ["Browser (SPA)"]
|
||||||
|
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
||||||
|
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Server ["Node.js (Server)"]
|
||||||
|
stdout["process.stdout.write"] --> capture["processStreamData()"]
|
||||||
|
stderr["process.stderr.write"] --> capture
|
||||||
|
capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
|
||||||
|
capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
|
||||||
|
|
||||||
|
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
|
||||||
|
ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In-Process Interceptor (Stdout & Stderr)
|
||||||
|
To guarantee 100% logging completeness without requiring complex and insecure external Docker daemon sockets, the server hooks directly into standard output stream writers `process.stdout.write` and `process.stderr.write` during the process boot cycle.
|
||||||
|
- Custom accumulators process streams, strip ANSI terminal colors (colors are stripped using a standard regex matching all terminal escape codes), and split incoming chunks into structured lines.
|
||||||
|
- Historical lines are appended to a rolling in-memory array `logBuffer` capped at 1,000 entries.
|
||||||
|
- Real-time logging events are broadcasted via a central `EventEmitter` allowing connected SSE stream clients to receive updates instantly.
|
||||||
|
|
||||||
|
#### Client Console Log Capture
|
||||||
|
To assist developers in troubleshooting client-side runtime errors without access to developer consoles (e.g., in embedded WebViews, smart TVs, or custom mobile browser instances), the SPA implements an automatic logging interceptor.
|
||||||
|
- If `/api/debug/status` indicates log streaming is active, the bootstrap process replaces global `console.log`, `console.warn`, and `console.error` methods.
|
||||||
|
- Logs are added to an in-memory queue and flushed in batches using a stateless `POST /api/debug/client-logs` request every 2,000ms (or when the queue size reaches 20 items) to prevent browser thread blocking.
|
||||||
|
- A synchronous cleanup check is registered via standard `beforeunload` to flush any remaining logs using `navigator.sendBeacon()` or `keepalive: true` fetch on page refresh or navigate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Directory Structure
|
## 8. Directory Structure
|
||||||
@@ -1188,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
||||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
|
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
|
||||||
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
||||||
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
||||||
|
|
||||||
@@ -1196,13 +1233,25 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
|
|
||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
|
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
|
||||||
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
||||||
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
||||||
| **Body size** | `express.json` body limit: 64 KB. |
|
| **Body size** | `express.json` body limit: 64 KB. |
|
||||||
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
|
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
|
||||||
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
|
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
|
||||||
|
|
||||||
|
### 10.4 Debug Log Streaming Security
|
||||||
|
|
||||||
|
Access to the debug log stream endpoints (`/api/debug/server-logs` and `/api/debug/client-logs`) is secured via a strict multi-layered policy:
|
||||||
|
|
||||||
|
| Layer | Mechanism |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Feature lock toggle** | Strictly disabled by default; only enableable when environment variable `ENABLE_LOG_STREAM=true` is set. |
|
||||||
|
| **Subnet filtering** | If environment variable `LOG_ALLOW_SUBNETS` is configured (using standard comma-separated CIDR notation), incoming client IPs (`req.ip`) are parsed and validated via the `ipaddr.js` library. Unmatched subnets receive a `403 Forbidden` response immediately. |
|
||||||
|
| **Fast-path webhook bypass** | A valid webhook secret bypasses the active Emby authentication layer when provided on the `X-Webhook-Secret` header. |
|
||||||
|
| **Active Emby session** | Validates that an active `emby_user` session cookie is present and that the authenticated Emby user is an administrator. |
|
||||||
|
| **Emby basic auth fallback** | Allows Basic Authentication headers by performing an on-demand asynchronous credentials check against Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to assert active policy administrator status. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Technology Stack
|
## 11. Technology Stack
|
||||||
|
|||||||
+301
@@ -4,6 +4,307 @@ 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||||
|
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||||
|
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||||
|
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||||
|
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||||
|
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
|
||||||
|
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
|
||||||
|
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
|
||||||
|
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
|
||||||
|
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
|
||||||
|
|
||||||
|
## [1.7.23] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
|
||||||
|
|
||||||
|
## [1.7.22] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
|
||||||
|
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
|
||||||
|
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
|
||||||
|
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
|
||||||
|
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
|
||||||
|
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
|
||||||
|
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
|
||||||
|
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.20] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.19] - 2026-05-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
|
||||||
|
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.18] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.17] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.16] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.15] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.14] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.13] - 2026-05-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||||
|
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||||
|
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.12] - 2026-05-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
|
||||||
|
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
|
||||||
|
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
|
||||||
|
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
|
||||||
|
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
|
||||||
|
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.11] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||||
|
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||||
|
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.10] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.9] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.8] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.7] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.6] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||||
|
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.7.5] - 2026-05-23
|
## [1.7.5] - 2026-05-23
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copy application source owned by root (read-only at runtime)
|
# Copy application source owned by root (read-only at runtime)
|
||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
COPY --from=client-build --chown=root:root /app/public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ PORT=3001 # Server port
|
|||||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||||
# Set to 0 or "off" to disable (on-demand mode)
|
# Set to 0 or "off" to disable (on-demand mode)
|
||||||
|
|
||||||
|
# Debug Log Streaming Subsystem
|
||||||
|
ENABLE_LOG_STREAM=false # Set to true to enable logs debugging routes
|
||||||
|
LOG_ALLOW_SUBNETS=127.0.0.1/32 # Comma-separated allowed CIDR blocks (e.g. 127.0.0.1/32,192.168.1.0/24)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Webhooks & Smart Polling
|
### Webhooks & Smart Polling
|
||||||
@@ -420,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
|
||||||
|
|
||||||
@@ -441,6 +445,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
|
|||||||
### History
|
### History
|
||||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||||
|
|
||||||
|
### Debug Logs (requires ENABLE_LOG_STREAM=true)
|
||||||
|
- `GET /api/debug/status` — Get runtime log stream configurations (public)
|
||||||
|
- `GET /api/debug/server-logs` — **SSE stream**: push server `stdout`/`stderr` in real-time (requires auth & subnet check)
|
||||||
|
- `GET /api/debug/client-logs` — **SSE stream**: push ingested frontend console logs in real-time (requires auth & subnet check)
|
||||||
|
- `POST /api/debug/client-logs` — Ingest batched frontend console logs (requires auth & subnet check)
|
||||||
|
|
||||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||||
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
||||||
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
||||||
@@ -464,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
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -40,7 +40,7 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
|||||||
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
|
||||||
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||||
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||||
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
|
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
|
||||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
||||||
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
|
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
|
||||||
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||||
@@ -162,12 +162,13 @@ server {
|
|||||||
|
|
||||||
## Rate Limits
|
## Rate Limits
|
||||||
|
|
||||||
| Endpoint | Limit |
|
| Endpoint | Limit | Details & Exemptions |
|
||||||
|----------|-------|
|
|----------|-------|----------------------|
|
||||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||||
|
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType
|
arrContentType: download.arrContentType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
|
|||||||
import { initTabs, goHome } from './ui/tabs.js';
|
import { initTabs, goHome } from './ui/tabs.js';
|
||||||
import { handleShowAllToggle } from './sse.js';
|
import { handleShowAllToggle } from './sse.js';
|
||||||
import { loadAppVersion } from './api.js';
|
import { loadAppVersion } from './api.js';
|
||||||
|
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize client console log capturing early
|
||||||
|
initClientLogCapture();
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
const loginForm = document.getElementById('login-form');
|
const loginForm = document.getElementById('login-form');
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -272,6 +277,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType,
|
arrContentType: download.arrContentType,
|
||||||
isAdmin: state.isAdmin,
|
isAdmin: state.isAdmin,
|
||||||
canBlocklist: download.canBlocklist
|
canBlocklist: download.canBlocklist
|
||||||
@@ -301,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
|
||||||
|
|||||||
+100
-20
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.user || request.User ||
|
||||||
return request.requestedUser.alias ||
|
request.requestedBy || request.RequestedBy ||
|
||||||
request.requestedUser.userAlias ||
|
request.ombiUser || request.OmbiUser ||
|
||||||
request.requestedUser.userName ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
request.requestedUser.normalizedUserName ||
|
|
||||||
request.requestedByAlias || '';
|
// If userSource is an object, extract key fields
|
||||||
|
if (userSource && typeof userSource === 'object') {
|
||||||
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
// If userSource is a string
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRequests() {
|
export function renderRequests() {
|
||||||
@@ -84,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 || ''}`;
|
||||||
@@ -111,11 +146,39 @@ function createRequestCard(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const username = extractRequestedUser(request);
|
const username = extractRequestedUser(request);
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = document.createElement('span');
|
|
||||||
user.className = 'request-user';
|
|
||||||
user.textContent = `Requested by: ${username}`;
|
user.textContent = `Requested by: ${username}`;
|
||||||
meta.appendChild(user);
|
} else {
|
||||||
|
user.textContent = 'Requested by: Unknown (Ombi)';
|
||||||
|
user.title = 'No user information received from Ombi';
|
||||||
|
user.style.cursor = 'help';
|
||||||
|
user.style.textDecoration = 'underline dotted';
|
||||||
|
}
|
||||||
|
meta.appendChild(user);
|
||||||
|
|
||||||
|
const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
|
||||||
|
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
|
||||||
|
if (dateStr) {
|
||||||
|
const requestDate = document.createElement('span');
|
||||||
|
requestDate.className = 'request-date';
|
||||||
|
try {
|
||||||
|
const dateObj = new Date(dateStr);
|
||||||
|
if (!isNaN(dateObj.getTime())) {
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(dateObj.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
|
||||||
|
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
meta.appendChild(requestDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.quality) {
|
if (request.quality) {
|
||||||
@@ -128,25 +191,42 @@ function createRequestCard(request) {
|
|||||||
content.appendChild(title);
|
content.appendChild(title);
|
||||||
content.appendChild(meta);
|
content.appendChild(meta);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'request-actions';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'request-link ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
|
||||||
ombiLink.target = '_blank';
|
ombiLink.target = '_blank';
|
||||||
ombiLink.title = 'View in Ombi';
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
const ombiIcon = document.createElement('img');
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.className = 'service-icon ombi';
|
||||||
ombiIcon.src = '/images/ombi.svg';
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
ombiIcon.alt = 'Ombi';
|
ombiIcon.alt = 'Ombi';
|
||||||
ombiIcon.className = 'request-icon';
|
|
||||||
|
|
||||||
ombiLink.appendChild(ombiIcon);
|
ombiLink.appendChild(ombiIcon);
|
||||||
actions.appendChild(ombiLink);
|
actions.appendChild(ombiLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && request.arrLink) {
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.className = `${request.arrType}-link`;
|
||||||
|
arrLink.href = request.arrLink;
|
||||||
|
arrLink.target = '_blank';
|
||||||
|
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
|
||||||
|
|
||||||
|
const arrIcon = document.createElement('img');
|
||||||
|
arrIcon.className = `service-icon ${request.arrType}`;
|
||||||
|
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||||
|
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
|
||||||
|
arrLink.appendChild(arrIcon);
|
||||||
|
actions.appendChild(arrLink);
|
||||||
|
}
|
||||||
|
|
||||||
card.appendChild(typeIcon);
|
card.appendChild(typeIcon);
|
||||||
card.appendChild(content);
|
card.appendChild(content);
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|||||||
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
|
|||||||
const wh = data.webhooks;
|
const wh = data.webhooks;
|
||||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||||
|
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
|
||||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||||
|
const ombiEvents = wh.ombi?.eventsReceived || 0;
|
||||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||||
|
const ombiPolls = wh.ombi?.pollsSkipped || 0;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<div class="status-card-title">Webhooks</div>
|
<div class="status-card-title">Webhooks</div>
|
||||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
|
||||||
|
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-10
@@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js';
|
|||||||
|
|
||||||
// Apply saved theme immediately on load
|
// Apply saved theme immediately on load
|
||||||
(function applyTheme() {
|
(function applyTheme() {
|
||||||
const theme = getTheme();
|
const theme = getTheme() || 'light';
|
||||||
if (theme) {
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function initThemeSwitcher() {
|
export function initThemeSwitcher() {
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
if (!themeToggle) return;
|
const currentTheme = getTheme() || 'light';
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
// Set initial active state on buttons
|
||||||
const currentTheme = getTheme();
|
themeButtons.forEach(btn => {
|
||||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||||
setTheme(newTheme);
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const theme = btn.getAttribute('data-theme');
|
||||||
|
if (theme) {
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme) {
|
export function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
saveTheme(theme);
|
saveTheme(theme);
|
||||||
|
|
||||||
|
// Sync button active classes if elements are present on the page
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
themeButtons.forEach(btn => {
|
||||||
|
if (btn.getAttribute('data-theme') === theme) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
const logQueue = [];
|
||||||
|
const MAX_QUEUE_SIZE = 20;
|
||||||
|
const FLUSH_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// Original console functions
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
let isSending = false;
|
||||||
|
let isInitialized = false;
|
||||||
|
let flushInterval = null;
|
||||||
|
|
||||||
|
function formatArgs(args) {
|
||||||
|
return args.map(arg => {
|
||||||
|
if (arg === null) return 'null';
|
||||||
|
if (arg === undefined) return 'undefined';
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
|
||||||
|
}
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(level, args) {
|
||||||
|
const formattedMsg = formatArgs(args);
|
||||||
|
|
||||||
|
// Still write to the developer console!
|
||||||
|
if (level === 'info') originalLog.apply(console, args);
|
||||||
|
else if (level === 'warn') originalWarn.apply(console, args);
|
||||||
|
else if (level === 'error') originalError.apply(console, args);
|
||||||
|
|
||||||
|
// Guard against infinite loop during logs dispatching
|
||||||
|
if (isSending) return;
|
||||||
|
|
||||||
|
logQueue.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message: formattedMsg
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush immediately if queue is full
|
||||||
|
if (logQueue.length >= MAX_QUEUE_SIZE) {
|
||||||
|
flushQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushQueue() {
|
||||||
|
if (logQueue.length === 0 || isSending) return;
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
const batch = [...logQueue];
|
||||||
|
logQueue.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/debug/client-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(batch),
|
||||||
|
// keepalive allows request to survive page unload
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform a fast/unblocked payload flush using sendBeacon on page unload
|
||||||
|
function flushOnUnload() {
|
||||||
|
if (logQueue.length === 0) return;
|
||||||
|
|
||||||
|
const batch = [...logQueue];
|
||||||
|
logQueue.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
||||||
|
navigator.sendBeacon('/api/debug/client-logs', blob);
|
||||||
|
} catch (err) {
|
||||||
|
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
|
||||||
|
try {
|
||||||
|
fetch('/api/debug/client-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(batch),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initClientLogCapture() {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check if the server toggle for logging is active
|
||||||
|
const response = await fetch('/api/debug/status');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.enabled === true) {
|
||||||
|
// 2. Override global console methods
|
||||||
|
console.log = (...args) => enqueue('info', args);
|
||||||
|
console.warn = (...args) => enqueue('warn', args);
|
||||||
|
console.error = (...args) => enqueue('error', args);
|
||||||
|
|
||||||
|
// 3. Set interval for batch updates
|
||||||
|
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// 4. Setup beforeunload listener for clean flushing
|
||||||
|
window.addEventListener('beforeunload', flushOnUnload);
|
||||||
|
|
||||||
|
isInitialized = true;
|
||||||
|
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-24
@@ -1,28 +1,50 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
build: {
|
// Load env variables from root directory to match backend TLS configuration
|
||||||
outDir: '../public',
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
emptyOutDir: false,
|
|
||||||
rollupOptions: {
|
const port = env.PORT || 3001;
|
||||||
input: {
|
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
main: './src/main.js'
|
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||||
},
|
|
||||||
output: {
|
return {
|
||||||
entryFileNames: 'app.js',
|
build: {
|
||||||
chunkFileNames: '[name].js',
|
// NOTE (Issue #66): `outDir` is intentionally the repo-root `../public`,
|
||||||
assetFileNames: '[name][extname]'
|
// NOT the Vite default `client/dist/`. The Express server in
|
||||||
|
// `server/app.js` serves static assets directly from `public/`, so the
|
||||||
|
// Vite build emits its bundle alongside the hand-authored static assets
|
||||||
|
// (favicon, etc.) that live in `public/` and are committed to the repo.
|
||||||
|
// Do NOT change this back to `dist/` without also updating the Express
|
||||||
|
// static-serve configuration and the Dockerfile copy steps.
|
||||||
|
outDir: '../public',
|
||||||
|
// NOTE (Issue #66): `emptyOutDir: false` is REQUIRED because `public/`
|
||||||
|
// contains hand-authored static assets that must survive the build.
|
||||||
|
// Setting this to `true` would wipe those assets on every `vite build`.
|
||||||
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: './src/main.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'app.js',
|
||||||
|
chunkFileNames: '[name].js',
|
||||||
|
assetFileNames: '[name][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true, // Listen on all network interfaces
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: target,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false // Allow self-signed certificate in development
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
server: {
|
});
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.38",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.38",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.38",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
|
"build": "npm run build --prefix client",
|
||||||
"install:all": "npm install",
|
"install:all": "npm install",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
+22
-20
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
|||||||
+109
-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;
|
||||||
@@ -1888,6 +1919,23 @@ body {
|
|||||||
|
|
||||||
/* ===== Mobile ===== */
|
/* ===== Mobile ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.main-tabs {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2212,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 {
|
||||||
@@ -2232,19 +2278,29 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.requests-list {
|
.requests-list {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
@@ -2253,14 +2309,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2270,12 +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;
|
||||||
white-space: nowrap;
|
word-break: break-word;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-meta {
|
.request-meta {
|
||||||
@@ -2363,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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+48
-1
@@ -15,6 +15,8 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
@@ -26,6 +28,7 @@ const historyRoutes = require('./routes/history');
|
|||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
const ombiRoutes = require('./routes/ombi');
|
const ombiRoutes = require('./routes/ombi');
|
||||||
|
const debugRoutes = require('./routes/debug');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
function createApp({ skipRateLimits = false } = {}) {
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
@@ -96,6 +99,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,13 +130,17 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* type: number
|
* type: number
|
||||||
* description: Server uptime in seconds
|
* description: Server uptime in seconds
|
||||||
* example: 3600.5
|
* example: 3600.5
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* description: sofarr version
|
||||||
|
* example: "1.7.38"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
* source: curl http://localhost:3001/health
|
* source: curl http://localhost:3001/health
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,6 +220,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/webhook', webhookRoutes);
|
app.use('/api/webhook', webhookRoutes);
|
||||||
|
app.use('/api/debug', debugRoutes);
|
||||||
|
|
||||||
// CSRF protection for all state-changing API requests below
|
// CSRF protection for all state-changing API requests below
|
||||||
app.use('/api', verifyCsrf);
|
app.use('/api', verifyCsrf);
|
||||||
@@ -224,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||||
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||||
|
app.use(express.static(PUBLIC_DIR, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -25,6 +25,41 @@ class DownloadClient {
|
|||||||
this.apiKey = instanceConfig.apiKey;
|
this.apiKey = instanceConfig.apiKey;
|
||||||
this.username = instanceConfig.username;
|
this.username = instanceConfig.username;
|
||||||
this.password = instanceConfig.password;
|
this.password = instanceConfig.password;
|
||||||
|
|
||||||
|
// Last error encountered while talking to this client.
|
||||||
|
// Cleared on successful calls via _clearLastError(); set via _recordLastError().
|
||||||
|
// Surfaced through getAllClientStatuses() so the admin status panel can show
|
||||||
|
// a per-client failure indicator without needing to scrape logs.
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an error encountered while talking to this client.
|
||||||
|
* @param {string} operation - Short description of the operation (e.g. 'getActiveDownloads')
|
||||||
|
* @param {Error|string} error - Error object or message
|
||||||
|
*/
|
||||||
|
_recordLastError(operation, error) {
|
||||||
|
const message = (error && error.message) ? error.message : String(error || 'unknown error');
|
||||||
|
this.lastError = {
|
||||||
|
operation,
|
||||||
|
message,
|
||||||
|
at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the last error (called when an operation succeeds).
|
||||||
|
*/
|
||||||
|
_clearLastError() {
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public accessor for the last recorded error, or null if none.
|
||||||
|
* @returns {{operation:string, message:string, at:string}|null}
|
||||||
|
*/
|
||||||
|
getLastError() {
|
||||||
|
return this.lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users from Ombi
|
||||||
|
* @returns {Promise<Array>} Array of user objects
|
||||||
|
*/
|
||||||
|
async getUsers() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = OmbiClient;
|
module.exports = OmbiClient;
|
||||||
|
|||||||
+122
-16
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
this.cache = {
|
this.cache = {
|
||||||
movieRequests: [],
|
movieRequests: [],
|
||||||
tvRequests: [],
|
tvRequests: [],
|
||||||
|
users: [],
|
||||||
movieMap: new Map(), // tmdbId -> request
|
movieMap: new Map(), // tmdbId -> request
|
||||||
tvMap: new Map(), // tvdbId -> request
|
tvMap: new Map(), // tvdbId -> request
|
||||||
|
userMap: new Map(), // id -> user
|
||||||
lastFetch: 0,
|
lastFetch: 0,
|
||||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||||
};
|
};
|
||||||
@@ -87,30 +89,43 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh cached data from Ombi API
|
* Refresh cached data from Ombi API
|
||||||
|
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async refreshCache() {
|
async refreshCache(force = false) {
|
||||||
if (!this.isCacheExpired()) {
|
if (!force && !this.isCacheExpired()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logToFile('[OmbiRetriever] Refreshing cache');
|
logToFile('[OmbiRetriever] Refreshing cache');
|
||||||
|
|
||||||
// Fetch requests in parallel
|
// Fetch requests and users in parallel
|
||||||
const [movieRequests, tvRequests] = await Promise.all([
|
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||||
this.client.getMovieRequests(),
|
this.client.getMovieRequests(),
|
||||||
this.client.getTvRequests()
|
this.client.getTvRequests(),
|
||||||
|
this.client.getUsers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cache.movieRequests = movieRequests;
|
this.cache.movieRequests = movieRequests;
|
||||||
this.cache.tvRequests = tvRequests;
|
this.cache.tvRequests = tvRequests;
|
||||||
|
this.cache.users = users;
|
||||||
this.cache.lastFetch = Date.now();
|
this.cache.lastFetch = Date.now();
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
this.cache.movieMap.clear();
|
this.cache.movieMap.clear();
|
||||||
this.cache.tvMap.clear();
|
this.cache.tvMap.clear();
|
||||||
|
this.cache.userMap.clear();
|
||||||
|
|
||||||
|
// Build user map (id -> user)
|
||||||
|
if (Array.isArray(users)) {
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user && user.id) {
|
||||||
|
this.cache.userMap.set(user.id, user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Build movie map (tmdbId -> request)
|
// Build movie map (tmdbId -> request)
|
||||||
movieRequests.forEach(request => {
|
movieRequests.forEach(request => {
|
||||||
@@ -132,29 +147,120 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||||
// Don't throw error, continue with stale cache if available
|
// Don't throw error, continue with stale cache if available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a single request using the userMap cache
|
||||||
|
* @param {Object} req - The request object
|
||||||
|
* @returns {Object} Hydrated request object
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequest(req) {
|
||||||
|
if (!req) return req;
|
||||||
|
|
||||||
|
let result = req;
|
||||||
|
|
||||||
|
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||||
|
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||||
|
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||||
|
|
||||||
|
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||||
|
|
||||||
|
// If requestedUser is not an object or is empty/null, populate it
|
||||||
|
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||||
|
const hydratedUser = {
|
||||||
|
id: cachedUser.id,
|
||||||
|
userName: cachedUser.userName,
|
||||||
|
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||||
|
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||||
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
result = {
|
||||||
|
...req,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate childRequests (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const hydratedChildren = result.childRequests.map(child => {
|
||||||
|
if (!child) return child;
|
||||||
|
|
||||||
|
const childUserId = child.requestedUserId || child.RequestedUserId;
|
||||||
|
if (childUserId && this.cache.userMap.has(childUserId)) {
|
||||||
|
const cachedUser = this.cache.userMap.get(childUserId);
|
||||||
|
let childUser = child.requestedUser || child.RequestedUser;
|
||||||
|
|
||||||
|
if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) {
|
||||||
|
const hydratedUser = {
|
||||||
|
id: cachedUser.id,
|
||||||
|
userName: cachedUser.userName,
|
||||||
|
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||||
|
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||||
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
result = { ...result, childRequests: hydratedChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote requestedDate from childRequests to top level (common for Ombi TV)
|
||||||
|
if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate;
|
||||||
|
if (childDate) {
|
||||||
|
result = { ...result, requestedDate: childDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||||
|
* @param {Array} requests - Array of request objects
|
||||||
|
* @returns {Array} Array of hydrated request objects
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequests(requests) {
|
||||||
|
if (!Array.isArray(requests)) return [];
|
||||||
|
return requests.map(req => this._hydrateRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all movie requests
|
* Get all movie requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of movie request objects
|
* @returns {Promise<Array>} Array of movie request objects
|
||||||
*/
|
*/
|
||||||
async getMovieRequests() {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.movieRequests;
|
return this._hydrateRequests(this.cache.movieRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all TV requests
|
* Get all TV requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of TV request objects
|
* @returns {Promise<Array>} Array of TV request objects
|
||||||
*/
|
*/
|
||||||
async getTvRequests() {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.tvRequests;
|
return this._hydrateRequests(this.cache.tvRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,12 +274,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TMDB ID first
|
// Try TMDB ID first
|
||||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||||
return this.cache.movieMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try IMDB ID as fallback
|
// Try IMDB ID as fallback
|
||||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||||
return this.cache.movieMap.get(imdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -190,12 +296,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TVDB ID first
|
// Try TVDB ID first
|
||||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||||
return this.cache.tvMap.get(tvdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try TMDB ID as fallback
|
// Try TMDB ID as fallback
|
||||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||||
return this.cache.tvMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
// Try a simple API call to verify connection
|
// Try a simple API call to verify connection
|
||||||
await this.makeRequest('/api/v2/app/version');
|
await this.makeRequest('/api/v2/app/version');
|
||||||
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
|
logToFile(`[qBittorrent:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`);
|
||||||
|
return Array.from(this.torrentMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
if (data.full_update) {
|
if (data.full_update) {
|
||||||
// Full refresh: rebuild the entire map
|
// Full refresh: rebuild the entire map
|
||||||
this.torrentMap.clear();
|
this.torrentMap.clear();
|
||||||
@@ -169,6 +176,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
|
|
||||||
const torrents = await this.getMainData();
|
const torrents = await this.getMainData();
|
||||||
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
|
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
|
||||||
|
this._clearLastError();
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`);
|
||||||
@@ -183,6 +191,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', fallbackError);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +202,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest('/api/v2/sync/maindata');
|
const response = await this.makeRequest('/api/v2/sync/maindata');
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
serverState: data.server_state || {},
|
serverState: data.server_state || {},
|
||||||
rid: data.rid,
|
rid: data.rid,
|
||||||
@@ -200,6 +210,7 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +260,14 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
downloaded: downloadedSize,
|
downloaded: downloadedSize,
|
||||||
speed: torrent.dlspeed,
|
speed: torrent.dlspeed,
|
||||||
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
||||||
|
// Connected peer counts (Issue #64). qBittorrent exposes:
|
||||||
|
// num_seeds — connected seeds (peers we have a connection to)
|
||||||
|
// num_leechs — connected leechers (peers downloading from us)
|
||||||
|
// num_complete / num_incomplete — *swarm* totals reported by tracker
|
||||||
|
// We expose the connected counts to stay consistent with what other
|
||||||
|
// clients (e.g. Transmission via peersConnected/peersSendingToUs) report.
|
||||||
|
seeds: torrent.num_seeds ?? 0,
|
||||||
|
peers: torrent.num_leechs ?? 0,
|
||||||
category: torrent.category || undefined,
|
category: torrent.category || undefined,
|
||||||
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
||||||
savePath: torrent.content_path || torrent.save_path || undefined,
|
savePath: torrent.content_path || torrent.save_path || undefined,
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ class RTorrentClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
await this._methodCall('system.client_version');
|
await this._methodCall('system.client_version');
|
||||||
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
logToFile(`[rtorrent:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,10 +79,31 @@ class RTorrentClient extends DownloadClient {
|
|||||||
'd.custom1='
|
'd.custom1='
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// rTorrent XML-RPC can occasionally return null/undefined or a non-array
|
||||||
|
// on misconfigured servers or transient errors. Guard against that here
|
||||||
|
// so callers always get a sane array instead of throwing on .map.
|
||||||
|
if (!Array.isArray(torrents)) {
|
||||||
|
logToFile(`[rtorrent:${this.name}] d.multicall2 returned non-array (${typeof torrents}); treating as empty`);
|
||||||
|
this._clearLastError();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
logToFile(`[rtorrent:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
this._clearLastError();
|
||||||
|
// Filter out any individual rows that fail to normalize so a single bad
|
||||||
|
// record cannot poison the whole result set.
|
||||||
|
const normalized = [];
|
||||||
|
for (const torrent of torrents) {
|
||||||
|
try {
|
||||||
|
normalized.push(this.normalizeDownload(torrent));
|
||||||
|
} catch (err) {
|
||||||
|
logToFile(`[rtorrent:${this.name}] Skipping malformed torrent row: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,31 +115,53 @@ class RTorrentClient extends DownloadClient {
|
|||||||
this._methodCall('throttle.global_up.rate')
|
this._methodCall('throttle.global_up.rate')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
globalDownRate: downRate,
|
globalDownRate: Number.isFinite(downRate) ? downRate : 0,
|
||||||
globalUpRate: upRate
|
globalUpRate: Number.isFinite(upRate) ? upRate : 0
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[rtorrent:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeDownload(torrent) {
|
normalizeDownload(torrent) {
|
||||||
|
// rTorrent's d.multicall2 returns an array of fields in the order requested.
|
||||||
|
// If a value is missing rtorrent typically returns '' or 0, but plugins and
|
||||||
|
// older versions can return undefined/null — coerce everything explicitly so
|
||||||
|
// downstream math and string ops never blow up on null/undefined.
|
||||||
|
if (!Array.isArray(torrent)) {
|
||||||
|
throw new Error('Expected torrent row to be an array');
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
hash,
|
hashRaw,
|
||||||
name,
|
nameRaw,
|
||||||
sizeBytes,
|
sizeBytesRaw,
|
||||||
completedBytes,
|
completedBytesRaw,
|
||||||
downRate,
|
downRateRaw,
|
||||||
upRate,
|
upRateRaw,
|
||||||
state,
|
stateRaw,
|
||||||
isActive,
|
isActiveRaw,
|
||||||
isHashChecking,
|
isHashCheckingRaw,
|
||||||
directory,
|
directoryRaw,
|
||||||
custom1
|
custom1Raw
|
||||||
] = torrent;
|
] = torrent;
|
||||||
|
|
||||||
|
const hash = hashRaw ? String(hashRaw) : '';
|
||||||
|
const name = nameRaw ? String(nameRaw) : '';
|
||||||
|
const sizeBytes = Number(sizeBytesRaw) || 0;
|
||||||
|
const completedBytes = Number(completedBytesRaw) || 0;
|
||||||
|
const downRate = Number(downRateRaw) || 0;
|
||||||
|
const upRate = Number(upRateRaw) || 0;
|
||||||
|
const state = Number.isFinite(Number(stateRaw)) ? Number(stateRaw) : 0;
|
||||||
|
const isActive = Number.isFinite(Number(isActiveRaw)) ? Number(isActiveRaw) : 0;
|
||||||
|
const isHashChecking = Number.isFinite(Number(isHashCheckingRaw)) ? Number(isHashCheckingRaw) : 0;
|
||||||
|
const directory = directoryRaw ? String(directoryRaw) : '';
|
||||||
|
const custom1 = custom1Raw ? String(custom1Raw) : '';
|
||||||
|
|
||||||
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
const status = this._mapStatus(state, isActive, isHashChecking, completedBytes, sizeBytes);
|
||||||
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
const progress = sizeBytes > 0 ? Math.round((completedBytes / sizeBytes) * 100) : 0;
|
||||||
|
|
||||||
@@ -168,6 +213,11 @@ class RTorrentClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_extractArrInfo(filename) {
|
_extractArrInfo(filename) {
|
||||||
|
// Null-safe: getActiveDownloads passes a normalized string, but guard anyway
|
||||||
|
// so callers passing raw rtorrent values cannot crash this helper.
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||||
if (seriesMatch) {
|
if (seriesMatch) {
|
||||||
return { type: 'series' };
|
return { type: 'series' };
|
||||||
|
|||||||
@@ -3,9 +3,27 @@ const axios = require('axios');
|
|||||||
const DownloadClient = require('./DownloadClient');
|
const DownloadClient = require('./DownloadClient');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
|
|
||||||
|
// Number of recently completed jobs to pull from SABnzbd's /api?mode=history on
|
||||||
|
// every poll. Larger values let DownloadMatcher correlate slightly older jobs
|
||||||
|
// with their Sonarr/Radarr queue entries at the cost of one wider HTTP
|
||||||
|
// response per poll cycle. Configurable via the SAB_HISTORY_LIMIT environment
|
||||||
|
// variable; defaults to 10 to match the previous hardcoded value.
|
||||||
|
const DEFAULT_HISTORY_LIMIT = 10;
|
||||||
|
function resolveHistoryLimit() {
|
||||||
|
const raw = process.env.SAB_HISTORY_LIMIT;
|
||||||
|
if (raw === undefined || raw === null || raw === '') return DEFAULT_HISTORY_LIMIT;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
logToFile(`[SABnzbd] Invalid SAB_HISTORY_LIMIT='${raw}'; falling back to ${DEFAULT_HISTORY_LIMIT}`);
|
||||||
|
return DEFAULT_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
class SABnzbdClient extends DownloadClient {
|
class SABnzbdClient extends DownloadClient {
|
||||||
constructor(instance) {
|
constructor(instance) {
|
||||||
super(instance);
|
super(instance);
|
||||||
|
this.historyLimit = resolveHistoryLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientType() {
|
getClientType() {
|
||||||
@@ -16,9 +34,11 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
const response = await this.makeRequest('', { mode: 'version' });
|
const response = await this.makeRequest('', { mode: 'version' });
|
||||||
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
|
logToFile(`[SABnzbd:${this.name}] Connection test successful, version: ${response.data.version}`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +67,7 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
// Get both queue and history to provide complete picture
|
// Get both queue and history to provide complete picture
|
||||||
const [queueResponse, historyResponse] = await Promise.all([
|
const [queueResponse, historyResponse] = await Promise.all([
|
||||||
this.makeRequest({ mode: 'queue' }),
|
this.makeRequest({ mode: 'queue' }),
|
||||||
this.makeRequest({ mode: 'history', limit: 10 })
|
this.makeRequest({ mode: 'history', limit: this.historyLimit })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const queueData = queueResponse.data;
|
const queueData = queueResponse.data;
|
||||||
@@ -99,10 +119,12 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads`);
|
logToFile(`[SABnzbd:${this.name}] Retrieved ${downloads.length} downloads (historyLimit=${this.historyLimit})`);
|
||||||
|
this._clearLastError();
|
||||||
return downloads;
|
return downloads;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Error fetching downloads: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,8 +134,12 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest({ mode: 'queue' });
|
const response = await this.makeRequest({ mode: 'queue' });
|
||||||
const queueData = response.data.queue;
|
const queueData = response.data.queue;
|
||||||
|
|
||||||
if (!queueData) return null;
|
if (!queueData) {
|
||||||
|
this._clearLastError();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
status: queueData.status,
|
status: queueData.status,
|
||||||
speed: queueData.speed,
|
speed: queueData.speed,
|
||||||
@@ -128,6 +154,7 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[SABnzbd:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
try {
|
try {
|
||||||
await this.makeRequest('session-get');
|
await this.makeRequest('session-get');
|
||||||
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
logToFile(`[Transmission:${this.name}] Connection test successful`);
|
||||||
|
this._clearLastError();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`);
|
||||||
|
this._recordLastError('testConnection', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,10 +82,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
|
|
||||||
const torrents = response.data.arguments.torrents || [];
|
const torrents = response.data.arguments.torrents || [];
|
||||||
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`);
|
||||||
|
this._clearLastError();
|
||||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`);
|
||||||
|
this._recordLastError('getActiveDownloads', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,12 +96,14 @@ class TransmissionClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest('session-get');
|
const response = await this.makeRequest('session-get');
|
||||||
const sessionStats = await this.makeRequest('session-stats');
|
const sessionStats = await this.makeRequest('session-stats');
|
||||||
|
|
||||||
|
this._clearLastError();
|
||||||
return {
|
return {
|
||||||
session: response.data.arguments,
|
session: response.data.arguments,
|
||||||
stats: sessionStats.data.arguments
|
stats: sessionStats.data.arguments
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`);
|
||||||
|
this._recordLastError('getClientStatus', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +118,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
4: 'Downloading', // TORRENT_DOWNLOAD
|
4: 'Downloading', // TORRENT_DOWNLOAD
|
||||||
5: 'Queued', // TORRENT_SEED_WAIT
|
5: 'Queued', // TORRENT_SEED_WAIT
|
||||||
6: 'Seeding', // TORRENT_SEED
|
6: 'Seeding', // TORRENT_SEED
|
||||||
7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2)
|
// Status code 7 is undocumented in the Transmission RPC spec (which
|
||||||
|
// formally defines only 0–6). The legacy alias "TORRENT_IS_CHECKING"
|
||||||
|
// (a duplicate of code 2) is the best-effort interpretation; map it to
|
||||||
|
// `Checking` so it is rendered usefully rather than as `Unknown`.
|
||||||
|
7: 'Checking' // TORRENT_IS_CHECKING (undocumented; treated as code 2)
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = statusMap[torrent.status] || 'Unknown';
|
const status = statusMap[torrent.status] || 'Unknown';
|
||||||
@@ -160,7 +169,11 @@ class TransmissionClient extends DownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extractArrInfo(filename) {
|
extractArrInfo(filename) {
|
||||||
// Similar to SABnzbdClient, try to extract Sonarr/Radarr info
|
// arrQueueId cannot be extracted from filename alone; *arr exposes that
|
||||||
|
// identifier only via its queue API. The reliable cross-client matching
|
||||||
|
// path is hash-based and lives in `DownloadMatcher.matchTorrents()` (see
|
||||||
|
// Issue #65), which keys on `torrent.hashString` for Transmission.
|
||||||
|
// This heuristic remains only to provide a coarse `type` hint.
|
||||||
|
|
||||||
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
// Look for patterns like "Series Name - S01E02 - Episode Title"
|
||||||
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i);
|
||||||
@@ -176,6 +189,39 @@ class TransmissionClient extends DownloadClient {
|
|||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start (resume) one or more torrents. `id` is the Transmission internal
|
||||||
|
* numeric id or a hashString; the RPC accepts either.
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
*/
|
||||||
|
async startTorrent(id) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-start', { ids });
|
||||||
|
logToFile(`[Transmission:${this.name}] Started torrent(s): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop (pause) one or more torrents.
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
*/
|
||||||
|
async stopTorrent(id) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-stop', { ids });
|
||||||
|
logToFile(`[Transmission:${this.name}] Stopped torrent(s): ${ids.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one or more torrents. When `deleteData` is true the local files
|
||||||
|
* are also deleted from disk (Transmission's `delete-local-data`).
|
||||||
|
* @param {number|string|Array<number|string>} id
|
||||||
|
* @param {boolean} [deleteData=false]
|
||||||
|
*/
|
||||||
|
async removeTorrent(id, deleteData = false) {
|
||||||
|
const ids = Array.isArray(id) ? id : [id];
|
||||||
|
await this.makeRequest('torrent-remove', { ids, 'delete-local-data': !!deleteData });
|
||||||
|
logToFile(`[Transmission:${this.name}] Removed torrent(s): ${ids.join(', ')} (deleteData=${!!deleteData})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TransmissionClient;
|
module.exports = TransmissionClient;
|
||||||
|
|||||||
+4
-284
@@ -13,6 +13,8 @@ const swaggerJsdoc = require('swagger-jsdoc');
|
|||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
require('./utils/loadSecrets')();
|
require('./utils/loadSecrets')();
|
||||||
|
const logCapture = require('./utils/logCapture');
|
||||||
|
logCapture.init();
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Setup logging with levels
|
// Setup logging with levels
|
||||||
@@ -80,19 +82,9 @@ console.error = function(...args) {
|
|||||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
|
||||||
const radarrRoutes = require('./routes/radarr');
|
|
||||||
const embyRoutes = require('./routes/emby');
|
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
|
||||||
const statusRoutes = require('./routes/status');
|
|
||||||
const historyRoutes = require('./routes/history');
|
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
const webhookRoutes = require('./routes/webhook');
|
|
||||||
const ombiRoutes = require('./routes/ombi');
|
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Startup environment validation
|
// Startup environment validation
|
||||||
@@ -114,282 +106,10 @@ if (process.env.EMBY_URL) {
|
|||||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Load OpenAPI spec from YAML
|
|
||||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
||||||
|
|
||||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
||||||
const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
...openapiSpec,
|
|
||||||
openapi: '3.1.0'
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, 'routes/*.js'),
|
|
||||||
path.join(__dirname, 'index.js')
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
||||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
||||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (process.env.TRUST_PROXY) {
|
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
|
||||||
: process.env.TRUST_PROXY;
|
|
||||||
app.set('trust proxy', trustValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helmet v7 — security response headers
|
|
||||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
||||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// Generate a fresh nonce for every request
|
|
||||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
formAction: ["'self'"],
|
|
||||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000, // 1 year
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true
|
|
||||||
},
|
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// General API rate limiter — applies to all /api/* routes
|
|
||||||
// More specific limiters (e.g. login) apply on top of this.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
message: { error: 'Too many requests, please try again later' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body parsing & cookies
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use(cookieParser(cookieSecret || undefined));
|
|
||||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /health:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Health check
|
|
||||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is healthy
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ok"
|
|
||||||
* uptime:
|
|
||||||
* type: number
|
|
||||||
* description: Server uptime in seconds
|
|
||||||
* example: 3600.5
|
|
||||||
* version:
|
|
||||||
* type: string
|
|
||||||
* description: sofarr version
|
|
||||||
* example: "1.6.0"
|
|
||||||
*/
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /ready:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Readiness check
|
|
||||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ready"
|
|
||||||
* '503':
|
|
||||||
* description: Server not ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "not ready"
|
|
||||||
* reason:
|
|
||||||
* type: string
|
|
||||||
* example: "EMBY_URL not configured"
|
|
||||||
*/
|
|
||||||
app.get('/ready', (req, res) => {
|
|
||||||
// Confirm critical config is present
|
|
||||||
const ready = !!(process.env.EMBY_URL);
|
|
||||||
if (ready) {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
} else {
|
|
||||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Swagger UI - publicly accessible API documentation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
||||||
customSiteTitle: 'sofarr API Documentation',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
customJs: [
|
|
||||||
'/swagger-auth-banner.js'
|
|
||||||
],
|
|
||||||
swaggerOptions: {
|
|
||||||
url: '/api/swagger.json'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
||||||
app.get('/api/swagger.json', (req, res) => {
|
|
||||||
// Clone the spec to avoid modifying the original
|
|
||||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
||||||
|
|
||||||
// Replace the server URL with the current request's origin
|
|
||||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(specCopy);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static files — served before API routes
|
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html.
|
|
||||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
||||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
||||||
app.use(express.static(PUBLIC_DIR, {
|
|
||||||
index: false,
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve index.html with CSP nonce injected into <script> tags
|
|
||||||
function serveIndex(req, res) {
|
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
|
||||||
const nonce = res.locals.cspNonce;
|
|
||||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
||||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
||||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
||||||
// the old nonce which no longer matches the per-request CSP header).
|
|
||||||
const patched = html
|
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.send(patched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
||||||
// CSRF protection applies to all state-changing /api/* requests except
|
|
||||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api', apiLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/webhook', webhookRoutes);
|
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
|
||||||
app.use('/api', verifyCsrf);
|
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
|
||||||
app.use('/api/radarr', radarrRoutes);
|
|
||||||
app.use('/api/emby', embyRoutes);
|
|
||||||
app.use('/api/ombi', ombiRoutes);
|
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
|
||||||
app.use('/api/status', statusRoutes);
|
|
||||||
app.use('/api/history', historyRoutes);
|
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
|
||||||
app.get('*', serveIndex);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global error handler — never leak stack traces to clients
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('[Server] Unhandled error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TLS / HTTPS support
|
// TLS / HTTPS support
|
||||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const axios = require('axios');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const ipaddr = require('ipaddr.js');
|
||||||
|
|
||||||
|
function getEmbyUrl() {
|
||||||
|
return process.env.EMBY_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpAllowed(clientIp, allowedSubnetsStr) {
|
||||||
|
if (!allowedSubnetsStr) return true;
|
||||||
|
try {
|
||||||
|
const clientIpParsed = ipaddr.parse(clientIp);
|
||||||
|
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const subnet of subnets) {
|
||||||
|
let rangeStr = subnet;
|
||||||
|
let bits = null;
|
||||||
|
if (subnet.includes('/')) {
|
||||||
|
const parts = subnet.split('/');
|
||||||
|
rangeStr = parts[0];
|
||||||
|
bits = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeIpParsed = ipaddr.parse(rangeStr);
|
||||||
|
|
||||||
|
if (bits === null) {
|
||||||
|
// Exact IP match
|
||||||
|
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Handle IPv4 mapped IPv6 address case
|
||||||
|
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||||
|
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match with subnet bits
|
||||||
|
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
|
||||||
|
if (clientIpParsed.match(rangeIpParsed, bits)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||||
|
// Handle IPv4 mapped IPv6 address case matching IPv4 range
|
||||||
|
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logStreamAuth(req, res, next) {
|
||||||
|
// 1. Subnet IP Filtering (First Priority)
|
||||||
|
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
|
||||||
|
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
|
||||||
|
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Webhook Secret Bypass (High Priority)
|
||||||
|
const secretHeader = req.headers['x-webhook-secret'];
|
||||||
|
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
if (configuredSecret && secretHeader === configuredSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Session Cookie
|
||||||
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
|
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||||
|
if (rawCookie && rawCookie !== false) {
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(rawCookie);
|
||||||
|
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
|
||||||
|
req.user = u;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors, fallback to basic auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Basic Authentication Fallback
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||||
|
try {
|
||||||
|
const credentialsBase64 = authHeader.substring(6);
|
||||||
|
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
|
||||||
|
const colonIdx = credentialsStr.indexOf(':');
|
||||||
|
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const username = credentialsStr.substring(0, colonIdx).trim();
|
||||||
|
const password = credentialsStr.substring(colonIdx + 1);
|
||||||
|
|
||||||
|
if (username && password) {
|
||||||
|
const embyUrl = getEmbyUrl();
|
||||||
|
if (!embyUrl) {
|
||||||
|
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||||
|
return res.status(401).json({ error: 'Authentication service unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate with Emby using stable DeviceId derived from username
|
||||||
|
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
|
||||||
|
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
|
||||||
|
Username: username,
|
||||||
|
Pw: password
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const authData = authResponse.data;
|
||||||
|
const userId = authData.User.Id || authData.User.id;
|
||||||
|
|
||||||
|
// Fetch detailed profile to verify administrator status
|
||||||
|
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
|
||||||
|
headers: {
|
||||||
|
'X-MediaBrowser-Token': authData.AccessToken
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userResponse.data;
|
||||||
|
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
|
||||||
|
req.user = { id: user.Id, name: user.Name, isAdmin: true };
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[logStreamAuth] Emby authentication error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unauthorized / Access Denied
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logStreamAuth;
|
||||||
+234
-10
@@ -12,13 +12,17 @@ info:
|
|||||||
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
- General API: 300 requests per 15 minutes per IP
|
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
|
||||||
- Login: 10 failed attempts per 15 minutes per IP
|
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
|
||||||
- Webhooks: 60 requests per minute per IP
|
- *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks.
|
||||||
|
- **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`.
|
||||||
|
- *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold.
|
||||||
|
- **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`).
|
||||||
|
- **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls.
|
||||||
|
|
||||||
## 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.6.0
|
version: 1.7.38
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -42,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
|
||||||
@@ -172,6 +176,27 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||||
example: "Request"
|
example: "Request"
|
||||||
|
arrLink:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
format: uri
|
||||||
|
description: Sonarr/Radarr show/movie web UI link (admin-only)
|
||||||
|
example: "http://sonarr:8989/series/show-slug"
|
||||||
|
downloadPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Save path in download client (admin-only)
|
||||||
|
example: "/downloads/series/show-slug"
|
||||||
|
targetPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Target path in library (admin-only)
|
||||||
|
example: "/tv/show-slug"
|
||||||
|
arrInstanceKey:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Sonarr/Radarr instance API key (admin-only)
|
||||||
|
example: "api-key-here"
|
||||||
|
|
||||||
DashboardPayload:
|
DashboardPayload:
|
||||||
type: object
|
type: object
|
||||||
@@ -276,7 +301,6 @@ components:
|
|||||||
- arrQueueId
|
- arrQueueId
|
||||||
- arrType
|
- arrType
|
||||||
- arrInstanceUrl
|
- arrInstanceUrl
|
||||||
- arrContentId
|
|
||||||
- arrContentType
|
- arrContentType
|
||||||
properties:
|
properties:
|
||||||
arrQueueId:
|
arrQueueId:
|
||||||
@@ -301,6 +325,16 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: episodeId (Sonarr) or movieId (Radarr)
|
description: episodeId (Sonarr) or movieId (Radarr)
|
||||||
example: 456
|
example: 456
|
||||||
|
arrContentIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Array of episodeIds for multi-episode packs (Sonarr)
|
||||||
|
example: [456, 457]
|
||||||
|
arrSeriesId:
|
||||||
|
type: integer
|
||||||
|
description: seriesId for fallback automatic series search (Sonarr)
|
||||||
|
example: 789
|
||||||
arrContentType:
|
arrContentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [episode, movie]
|
enum: [episode, movie]
|
||||||
@@ -782,8 +816,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Sonarr webhook
|
summary: Sonarr webhook
|
||||||
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -819,8 +860,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Radarr webhook
|
summary: Radarr webhook
|
||||||
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -856,8 +904,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Ombi webhook
|
summary: Ombi webhook
|
||||||
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
|
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||||
security: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1743,3 +1798,172 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/api/debug/status:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Check if log streaming is enabled
|
||||||
|
description: Returns whether the log streaming feature is enabled at runtime. No authentication required.
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Feature status returned successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
|
||||||
|
/api/debug/server-logs:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Stream server logs in real-time
|
||||||
|
description: |
|
||||||
|
Streams server-side standard output (stdout/stderr) logs via Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Event stream established
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/api/debug/client-logs:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Stream client console logs in real-time
|
||||||
|
description: |
|
||||||
|
Streams client-side console logs via Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Event stream established
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Ingest client console logs
|
||||||
|
description: |
|
||||||
|
Ingests a batch of client-side console logs into the server-side rolling clientLogBuffer.
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [level, message]
|
||||||
|
properties:
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
level:
|
||||||
|
type: string
|
||||||
|
enum: [info, warn, error]
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Logs ingested successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Invalid JSON body
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
|||||||
+91
-19
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
|||||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||||
|
|
||||||
|
|
||||||
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
|||||||
function buildMetadataMaps(snapshot) {
|
function buildMetadataMaps(snapshot) {
|
||||||
const seriesMap = new Map();
|
const seriesMap = new Map();
|
||||||
for (const r of snapshot.sonarrQueue.data.records) {
|
for (const r of snapshot.sonarrQueue.data.records) {
|
||||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
if (r.series && r.seriesId) {
|
||||||
|
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||||
|
r.series._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const r of snapshot.sonarrHistory.data.records) {
|
for (const r of snapshot.sonarrHistory.data.records) {
|
||||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
if (r.series && r.seriesId) {
|
||||||
|
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||||
|
r.series._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
const existing = seriesMap.get(r.seriesId);
|
||||||
|
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
|
||||||
|
seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const moviesMap = new Map();
|
const moviesMap = new Map();
|
||||||
for (const r of snapshot.radarrQueue.data.records) {
|
for (const r of snapshot.radarrQueue.data.records) {
|
||||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
if (r.movie && r.movieId) {
|
||||||
|
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||||
|
r.movie._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const r of snapshot.radarrHistory.data.records) {
|
for (const r of snapshot.radarrHistory.data.records) {
|
||||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
if (r.movie && r.movieId) {
|
||||||
|
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||||
|
r.movie._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
const existing = moviesMap.get(r.movieId);
|
||||||
|
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
|
||||||
|
moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||||
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
||||||
@@ -192,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -487,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||||
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
||||||
id: c.getInstanceId(),
|
id: c.getInstanceId(),
|
||||||
@@ -494,13 +528,29 @@ 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
|
||||||
|
|
||||||
|
|
||||||
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
|
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
|
||||||
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
|
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
|
||||||
|
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
const ombiRequestsFiltered = {
|
const ombiRequestsFiltered = {
|
||||||
movie: filteredOmbiMovieRequests,
|
movie: filteredOmbiMovieRequests,
|
||||||
@@ -676,27 +726,43 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
|
||||||
|
|
||||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||||
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the download to verify permission
|
// Look up the queue record directly from the *arr cache.
|
||||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
// downloadClientRegistry.getAllDownloads() returns raw download-client data
|
||||||
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
|
||||||
|
// is only populated later by DownloadMatcher during the SSE build phase.
|
||||||
|
// Instead, we verify permission by finding the record in the Sonarr/Radarr
|
||||||
|
// queue cache where record.id is the numeric queue ID.
|
||||||
|
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
|
||||||
|
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
|
||||||
|
const queueData = cache.get(queueCacheKey) || { records: [] };
|
||||||
|
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
|
||||||
|
|
||||||
if (!download) {
|
if (!queueRecord) {
|
||||||
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
|
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Download not found or permission denied' });
|
return res.status(403).json({ error: 'Download not found or permission denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a minimal download-like object for canBlocklist eligibility check.
|
||||||
|
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
|
||||||
|
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
|
||||||
|
const downloadForCheck = {
|
||||||
|
importIssues: importIssues || [],
|
||||||
|
arrQueueId: queueRecord.id,
|
||||||
|
arrType
|
||||||
|
};
|
||||||
|
|
||||||
// Check if user can blocklist this download
|
// Check if user can blocklist this download
|
||||||
if (!canBlocklist(download, user.isAdmin)) {
|
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
|
||||||
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
|
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
||||||
}
|
}
|
||||||
@@ -724,8 +790,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
// Step 2: Trigger a new automatic search
|
// Step 2: Trigger a new automatic search
|
||||||
let commandBody;
|
let commandBody;
|
||||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
if (arrContentId) {
|
||||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||||
|
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
|
||||||
|
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
|
||||||
|
} else if (arrSeriesId) {
|
||||||
|
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
|
||||||
|
}
|
||||||
|
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
|
||||||
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +809,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
const { pollAllServices } = require('../utils/poller');
|
const { pollAllServices } = require('../utils/poller');
|
||||||
pollAllServices().catch(() => {});
|
pollAllServices().catch(() => {});
|
||||||
|
|
||||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||||
|
const {
|
||||||
|
logEmitter,
|
||||||
|
logBuffer,
|
||||||
|
clientLogBuffer,
|
||||||
|
ingestClientLogs
|
||||||
|
} = require('../utils/logCapture');
|
||||||
|
|
||||||
|
// Public status check (no auth, no 403 block, returns standard config state)
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global toggle check
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||||
|
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enforce subnet and authentication validations on all debug routes
|
||||||
|
router.use(logStreamAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/debug/server-logs
|
||||||
|
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||||
|
*/
|
||||||
|
router.get('/server-logs', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send historical server logs buffer first
|
||||||
|
for (const line of logBuffer) {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gracefully close for integration testing
|
||||||
|
if (req.query.testClose === 'true') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendLog = (line) => {
|
||||||
|
try {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logEmitter.on('server-log', sendLog);
|
||||||
|
|
||||||
|
// 25s heartbeat comment to prevent proxy timeouts
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
logEmitter.off('server-log', sendLog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/debug/client-logs
|
||||||
|
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||||
|
*/
|
||||||
|
router.get('/client-logs', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send historical client logs buffer first
|
||||||
|
for (const line of clientLogBuffer) {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gracefully close for integration testing
|
||||||
|
if (req.query.testClose === 'true') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendClientLog = (line) => {
|
||||||
|
try {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logEmitter.on('client-log', sendClientLog);
|
||||||
|
|
||||||
|
// 25s heartbeat comment to prevent proxy timeouts
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
logEmitter.off('client-log', sendClientLog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/debug/client-logs
|
||||||
|
* Receives batches of frontend console logs to store in buffer and emit.
|
||||||
|
*/
|
||||||
|
router.post('/client-logs', (req, res) => {
|
||||||
|
const logs = req.body;
|
||||||
|
if (!Array.isArray(logs)) {
|
||||||
|
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ingestClientLogs(logs);
|
||||||
|
return res.status(200).json({ success: true, count: logs.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||||
|
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+74
-23
@@ -2,9 +2,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
// initialize() is idempotent - cheap no-op if already initialized
|
// initialize() is idempotent - cheap no-op if already initialized
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
// Filter by user if not admin or if showAll is false
|
// Filter by user if not admin or if showAll is false
|
||||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||||
@@ -126,6 +126,11 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateRequestsWithArrLinks(allRequests, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
let types = req.query.type;
|
let types = req.query.type;
|
||||||
let statuses = req.query.status;
|
let statuses = req.query.status;
|
||||||
@@ -205,10 +210,10 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -221,7 +226,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||||
|
|
||||||
// Call Ombi API to register webhook
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
@@ -462,10 +467,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -478,25 +483,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
// Simulate a test webhook event
|
// Simulate a test webhook event
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
await axios.post(webhookUrl, {
|
try {
|
||||||
notificationType: 'RequestAvailable',
|
await axios.post(webhookUrl, {
|
||||||
requestId: 0,
|
notificationType: 'RequestAvailable',
|
||||||
requestedUser: 'test',
|
requestId: 0,
|
||||||
title: 'Test Request',
|
requestedUser: 'test',
|
||||||
type: 'Movie',
|
title: 'Test Request',
|
||||||
requestStatus: 'Pending'
|
type: 'Movie',
|
||||||
}, {
|
requestStatus: 'Pending'
|
||||||
headers: {
|
}, {
|
||||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
}
|
'Content-Type': 'application/json'
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
|
||||||
|
|
||||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
const port = process.env.PORT || 3001;
|
||||||
|
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
let useHttps = false;
|
||||||
|
if (tlsEnabled) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const certsDir = path.join(__dirname, '../../certs');
|
||||||
|
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
|
||||||
|
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
|
||||||
|
try {
|
||||||
|
fs.readFileSync(tlsCertPath);
|
||||||
|
fs.readFileSync(tlsKeyPath);
|
||||||
|
useHttps = true;
|
||||||
|
} catch {
|
||||||
|
useHttps = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post(localUrl, {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 0,
|
||||||
|
requestedUser: 'test',
|
||||||
|
title: 'Test Request',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending'
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
httpsAgent: useHttps ? agent : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Radarr instance (for notification proxy routes)
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
function getFirstRadarrInstance() {
|
function getFirstRadarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Radarr not configured' });
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Sonarr instance (for notification proxy routes)
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
function getFirstSonarrInstance() {
|
function getFirstSonarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
+23
-5
@@ -4,9 +4,10 @@ const router = express.Router();
|
|||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||||
|
const downloadClientRegistry = require('../utils/downloadClients');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -121,6 +122,7 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
// Check webhook configuration for each service
|
// Check webhook configuration for each service
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||||
@@ -128,15 +130,21 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||||
: false;
|
: false;
|
||||||
|
const ombiWebhookConfigured = ombiInstances.length > 0
|
||||||
|
? await checkOmbiWebhookConfigured(ombiInstances[0])
|
||||||
|
: false;
|
||||||
|
|
||||||
// Find Sonarr and Radarr metrics from instances
|
// Find Sonarr, Radarr, and Ombi metrics from instances
|
||||||
const sonarrMetrics = {};
|
const sonarrMetrics = {};
|
||||||
const radarrMetrics = {};
|
const radarrMetrics = {};
|
||||||
|
const ombiMetrics = {};
|
||||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||||
if (url.includes('sonarr')) {
|
if (url.includes('sonarr')) {
|
||||||
sonarrMetrics[url] = metrics;
|
sonarrMetrics[url] = metrics;
|
||||||
} else if (url.includes('radarr')) {
|
} else if (url.includes('radarr')) {
|
||||||
radarrMetrics[url] = metrics;
|
radarrMetrics[url] = metrics;
|
||||||
|
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
|
||||||
|
ombiMetrics[url] = metrics;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,8 +164,18 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
cache: cacheStats,
|
cache: cacheStats,
|
||||||
webhooks: {
|
webhooks: {
|
||||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||||
}
|
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 });
|
||||||
|
|||||||
+140
-51
@@ -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');
|
||||||
@@ -87,7 +88,7 @@ const VALID_EVENT_TYPES = new Set([
|
|||||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||||
// Ombi notification types
|
// Ombi notification types
|
||||||
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||||
@@ -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;
|
||||||
@@ -135,6 +141,7 @@ const HISTORY_EVENTS = new Set([
|
|||||||
|
|
||||||
// Ombi event types — all Ombi events refresh the requests cache
|
// Ombi event types — all Ombi events refresh the requests cache
|
||||||
const OMBI_EVENTS = new Set([
|
const OMBI_EVENTS = new Set([
|
||||||
|
'NewRequest',
|
||||||
'RequestAvailable',
|
'RequestAvailable',
|
||||||
'RequestApproved',
|
'RequestApproved',
|
||||||
'RequestDeclined',
|
'RequestDeclined',
|
||||||
@@ -143,13 +150,13 @@ const OMBI_EVENTS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
||||||
* @param {Object} req - Express request object
|
* @param {Object} req - Express request object
|
||||||
* @returns {boolean} True if secret is valid, false otherwise
|
* @returns {boolean} True if secret is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
function validateWebhookSecret(req) {
|
function validateWebhookSecret(req) {
|
||||||
const expectedSecret = getWebhookSecret();
|
const expectedSecret = getWebhookSecret();
|
||||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||||
|
|
||||||
if (!expectedSecret) {
|
if (!expectedSecret) {
|
||||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||||
@@ -157,7 +164,7 @@ function validateWebhookSecret(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!providedSecret) {
|
if (!providedSecret) {
|
||||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
|
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +186,7 @@ function validateWebhookSecret(req) {
|
|||||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
@@ -201,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const sonarrQueues = queuesByType.sonarr || [];
|
const sonarrQueues = queuesByType.sonarr || [];
|
||||||
cache.set('poll:sonarr-queue', {
|
cache.set('poll:sonarr-queue', {
|
||||||
records: sonarrQueues.flatMap(q => {
|
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.series) r.series._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -231,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const radarrQueues = queuesByType.radarr || [];
|
const radarrQueues = queuesByType.radarr || [];
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -258,7 +245,66 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const ombiInstances = getOmbiInstances();
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
if (affectsOmbi) {
|
if (affectsOmbi) {
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||||
|
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||||
|
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
||||||
|
await new Promise(r => setTimeout(r, initialDelay));
|
||||||
|
|
||||||
|
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
||||||
|
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
||||||
|
|
||||||
|
let ombiRequests = { movie: [], tv: [] };
|
||||||
|
let foundAndValid = false;
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelayMs = 1500;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 1) {
|
||||||
|
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in movie or tv lists
|
||||||
|
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
||||||
|
// Also check both if mediaType not specified
|
||||||
|
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
const user = extractRequestedUser(targetReq);
|
||||||
|
if (user) {
|
||||||
|
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAndValid && requestId) {
|
||||||
|
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
||||||
|
// Try to log the raw target request if we found one
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||||
}
|
}
|
||||||
@@ -306,13 +352,13 @@ function validatePayload(body) {
|
|||||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -339,6 +385,13 @@ function validatePayload(body) {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -413,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,13 +516,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -486,6 +549,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -560,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,13 +680,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||||
*
|
*
|
||||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||||
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **Validation:**
|
||||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||||
* - Payload validation (must be JSON object with notificationType, requestId)
|
* - Payload validation (must be JSON object with notificationType, requestId)
|
||||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -624,11 +704,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
||||||
*
|
*
|
||||||
* **x-integration-notes:** Configure Ombi webhook:
|
* **x-integration-notes:** Configure Ombi webhook:
|
||||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
|
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Method: POST
|
* - Method: POST
|
||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
|
||||||
* - Application Token: OMBI_API_KEY
|
* - Application Token: OMBI_API_KEY
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -716,9 +802,12 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Unauthorized' });
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ombi uses notificationType instead of eventType
|
// Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
|
||||||
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
|
const notificationType = req.body.notificationType || req.body.NotificationType;
|
||||||
const eventType = notificationType || req.body.eventType;
|
const requestId = req.body.requestId || req.body.RequestId;
|
||||||
|
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
||||||
|
|
||||||
|
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
||||||
|
|
||||||
// Extract username from requestedUser (handles both object and string formats)
|
// Extract username from requestedUser (handles both object and string formats)
|
||||||
const username = extractRequestedUser(req.body);
|
const username = extractRequestedUser(req.body);
|
||||||
@@ -730,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 || 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 });
|
||||||
}
|
}
|
||||||
@@ -751,7 +840,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background cache refresh + SSE broadcast (fire-and-forget)
|
// Background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
processWebhookEvent('ombi', eventType).catch(err => {
|
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+422
-424
@@ -9,6 +9,199 @@
|
|||||||
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.
|
||||||
@@ -90,302 +283,107 @@ 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;
|
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name;
|
||||||
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;
|
||||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabSlots');
|
||||||
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
|
||||||
|
|
||||||
// Also check HISTORY by downloadId
|
// Progress calculation
|
||||||
if (!sonarrMatch && sabDownloadId) {
|
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||||
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||||
}
|
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||||
if (!radarrMatch && sabDownloadId) {
|
: 0;
|
||||||
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Check by title matching
|
const commonOptions = {
|
||||||
if (!sonarrMatch) {
|
title: nzbName,
|
||||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
status: slotState.status,
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
progress: Math.round(progress),
|
||||||
return rTitle && (
|
mb: slot.mb,
|
||||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
size: Math.round(slot.mb * 1024 * 1024),
|
||||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
client: 'sabnzbd',
|
||||||
);
|
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||||
});
|
instanceName: slot.instanceName || 'SABnzbd',
|
||||||
}
|
downloadPath: slot.storage || null,
|
||||||
if (!radarrMatch) {
|
overrides: {
|
||||||
radarrMatch = radarrQueueRecords.find(r => {
|
mbmissing: slot.mbleft,
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||||
return rTitle && (
|
eta: slot.timeleft
|
||||||
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 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(nzbNameLower, sonarrQueueRecords)
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
});
|
||||||
// Calculate progress from SABnzbd slot data
|
if (dlObj) matched.push(dlObj);
|
||||||
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.arrContentType = 'episode';
|
|
||||||
if (isAdmin) {
|
|
||||||
dlObj.downloadPath = slot.storage || null;
|
|
||||||
dlObj.targetPath = series.path || null;
|
|
||||||
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 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;
|
|
||||||
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) {
|
|
||||||
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
|
||||||
if (!nzbName) continue;
|
|
||||||
const nzbNameLower = nzbName.toLowerCase();
|
|
||||||
|
|
||||||
const sonarrMatch = sonarrHistoryRecords.find(r => {
|
for (const slot of slots) {
|
||||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
const nzbName = slot.filename || slot.nzbname || slot.name || slot.nzb_name;
|
||||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
if (!nzbName) continue;
|
||||||
});
|
|
||||||
|
const sabDownloadId = slot.nzo_id || slot.id;
|
||||||
|
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory');
|
||||||
|
|
||||||
|
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 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(nzbName.toLowerCase(), context.sonarrHistoryRecords || [])
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
});
|
||||||
const dlObj = {
|
if (dlObj) matched.push(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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,17 +398,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 = [];
|
||||||
@@ -419,169 +407,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.arrContentType = 'episode';
|
|
||||||
if (isAdmin) {
|
|
||||||
download.downloadPath = download.savePath || null;
|
|
||||||
download.targetPath = series.path || null;
|
|
||||||
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;
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,5 +588,7 @@ module.exports = {
|
|||||||
addOmbiMatching,
|
addOmbiMatching,
|
||||||
matchSabSlots,
|
matchSabSlots,
|
||||||
matchSabHistory,
|
matchSabHistory,
|
||||||
matchTorrents
|
matchTorrents,
|
||||||
|
buildArrDownload,
|
||||||
|
matchOrphanedArrRecords
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||||
|
* @param {Object} instance - The Ombi instance config
|
||||||
|
* @returns {Promise<boolean>} true if webhook is configured
|
||||||
|
*/
|
||||||
|
async function checkOmbiWebhookConfigured(instance) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||||
|
headers: { 'ApiKey': instance.apiKey },
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
return !!(response.data && response.data.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
checkWebhookConfigured,
|
checkWebhookConfigured,
|
||||||
|
checkOmbiWebhookConfigured,
|
||||||
aggregateMetrics
|
aggregateMetrics
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Ombi requests
|
* Get all Ombi requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||||
*/
|
*/
|
||||||
async getOmbiRequests() {
|
async getOmbiRequests(force = false) {
|
||||||
const ombiRetrievers = this.getOmbiRetrievers();
|
const ombiRetrievers = this.getOmbiRetrievers();
|
||||||
if (ombiRetrievers.length === 0) {
|
if (ombiRetrievers.length === 0) {
|
||||||
return { movie: [], tv: [] };
|
return { movie: [], tv: [] };
|
||||||
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
|
|||||||
// Use the first Ombi retriever (single instance expected)
|
// Use the first Ombi retriever (single instance expected)
|
||||||
const retriever = ombiRetrievers[0];
|
const retriever = ombiRetrievers[0];
|
||||||
try {
|
try {
|
||||||
const movieRequests = await retriever.getMovieRequests();
|
const movieRequests = await retriever.getMovieRequests(force);
|
||||||
const tvRequests = await retriever.getTvRequests();
|
const tvRequests = await retriever.getTvRequests(false);
|
||||||
return { movie: movieRequests, tv: tvRequests };
|
return { movie: movieRequests, tv: tvRequests };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||||
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Ombi requests grouped by type
|
* Get Ombi requests grouped by type
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||||
*/
|
*/
|
||||||
async getOmbiRequestsByType() {
|
async getOmbiRequestsByType(force = false) {
|
||||||
return await this.getOmbiRequests();
|
return await this.getOmbiRequests(force);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
|
|||||||
return process.env.SOFARR_BASE_URL || '';
|
return process.env.SOFARR_BASE_URL || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSofarrWebhookBaseUrl() {
|
||||||
|
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -140,6 +144,7 @@ module.exports = {
|
|||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
getWebhookSecret,
|
getWebhookSecret,
|
||||||
getSofarrBaseUrl,
|
getSofarrBaseUrl,
|
||||||
|
getSofarrWebhookBaseUrl,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
class LogEmitter extends EventEmitter {}
|
||||||
|
const logEmitter = new LogEmitter();
|
||||||
|
|
||||||
|
const logBuffer = [];
|
||||||
|
const clientLogBuffer = [];
|
||||||
|
const MAX_BUFFER_SIZE = 1000;
|
||||||
|
|
||||||
|
// ANSI escape code regular expression for stripping terminal colors
|
||||||
|
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||||
|
|
||||||
|
function stripAnsi(str) {
|
||||||
|
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of original stdout/stderr write functions
|
||||||
|
const originalStdoutWrite = process.stdout.write;
|
||||||
|
const originalStderrWrite = process.stderr.write;
|
||||||
|
|
||||||
|
// Buffer to accumulate partial lines from stdout and stderr
|
||||||
|
let stdoutLineBuffer = '';
|
||||||
|
let stderrLineBuffer = '';
|
||||||
|
|
||||||
|
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
|
||||||
|
let str = '';
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
str = data.toString(encoding || 'utf8');
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
str = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate writing to the original stream first
|
||||||
|
callback.call(this, data, encoding);
|
||||||
|
|
||||||
|
// Append new data to the accumulator
|
||||||
|
const accumulated = lineAccumulator.buffer + str;
|
||||||
|
const lines = accumulated.split(/\r?\n/);
|
||||||
|
|
||||||
|
// The last element is either empty (if str ended with \n) or a partial line
|
||||||
|
lineAccumulator.buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const cleanLine = stripAnsi(line);
|
||||||
|
if (!cleanLine) continue;
|
||||||
|
|
||||||
|
// Prepend timestamp if not present (format: [ISO] Message)
|
||||||
|
const timestampedLine = cleanLine.startsWith('[')
|
||||||
|
? cleanLine
|
||||||
|
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
|
||||||
|
|
||||||
|
logBuffer.push(timestampedLine);
|
||||||
|
if (logBuffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
logBuffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
logEmitter.emit('server-log', timestampedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulator objects to allow updating string buffers by reference
|
||||||
|
const stdoutAccumulator = { buffer: '' };
|
||||||
|
const stderrAccumulator = { buffer: '' };
|
||||||
|
|
||||||
|
let isHooked = false;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (isHooked) return;
|
||||||
|
|
||||||
|
// Intercept stdout
|
||||||
|
process.stdout.write = function(data, encoding, callback) {
|
||||||
|
processStreamData.call(
|
||||||
|
process.stdout,
|
||||||
|
data,
|
||||||
|
encoding,
|
||||||
|
originalStdoutWrite,
|
||||||
|
'stdout',
|
||||||
|
stdoutAccumulator
|
||||||
|
);
|
||||||
|
if (typeof callback === 'function') callback();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept stderr
|
||||||
|
process.stderr.write = function(data, encoding, callback) {
|
||||||
|
processStreamData.call(
|
||||||
|
process.stderr,
|
||||||
|
data,
|
||||||
|
encoding,
|
||||||
|
originalStderrWrite,
|
||||||
|
'stderr',
|
||||||
|
stderrAccumulator
|
||||||
|
);
|
||||||
|
if (typeof callback === 'function') callback();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
isHooked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingests a list of client-side logs into the rolling clientLogBuffer.
|
||||||
|
* Each client log is expected to have structure: { timestamp, level, message }
|
||||||
|
*/
|
||||||
|
function ingestClientLogs(logs) {
|
||||||
|
if (!Array.isArray(logs)) return;
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
const timestamp = log.timestamp || new Date().toISOString();
|
||||||
|
const level = (log.level || 'info').toUpperCase();
|
||||||
|
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
|
||||||
|
|
||||||
|
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
|
||||||
|
clientLogBuffer.push(formattedLog);
|
||||||
|
|
||||||
|
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
clientLogBuffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
logEmitter.emit('client-log', formattedLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
logEmitter,
|
||||||
|
logBuffer,
|
||||||
|
clientLogBuffer,
|
||||||
|
ingestClientLogs
|
||||||
|
};
|
||||||
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+206
-11
@@ -5,6 +5,8 @@
|
|||||||
* not a string, so we need to extract the username from the object.
|
* not a string, so we need to extract the username from the object.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the username from an Ombi request object.
|
* Extracts the username from an Ombi request object.
|
||||||
* Handles both the OmbiUser object format and legacy string format.
|
* Handles both the OmbiUser object format and legacy string format.
|
||||||
@@ -15,17 +17,57 @@
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.user || request.User ||
|
||||||
return request.requestedUser.alias ||
|
request.requestedBy || request.RequestedBy ||
|
||||||
request.requestedUser.userAlias ||
|
request.ombiUser || request.OmbiUser ||
|
||||||
request.requestedUser.userName ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
request.requestedUser.normalizedUserName ||
|
|
||||||
request.requestedByAlias || '';
|
// If userSource is an object, extract key fields
|
||||||
|
if (userSource && typeof userSource === 'object') {
|
||||||
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
// If userSource is a string and not an empty object/array
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warning log when user extraction returns empty for non-empty requests
|
||||||
|
if (Object.keys(request).length > 0 && !request.notificationType) {
|
||||||
|
logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRequestsByUser(requests, username, showAll) {
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
@@ -38,7 +80,160 @@ function filterRequestsByUser(requests, username, showAll) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(requests)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
requests.forEach(req => {
|
||||||
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
|
// Fallback to checking for TV specific IDs.
|
||||||
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
|
if (!tvdbId) return;
|
||||||
|
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
req.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
|
||||||
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
req.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(downloads)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
downloads.forEach(dl => {
|
||||||
|
// Determine if it's TV (series) or Movie
|
||||||
|
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// Look for a match in Sonarr instances
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => {
|
||||||
|
if (!s) return false;
|
||||||
|
// Match by database series ID if the instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to seriesName matching
|
||||||
|
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
|
||||||
|
// Look for a match in Radarr instances
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => {
|
||||||
|
if (!m) return false;
|
||||||
|
// Match by database movie ID if instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to movieName matching
|
||||||
|
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extractRequestedUser,
|
extractRequestedUser,
|
||||||
filterRequestsByUser
|
filterRequestsByUser,
|
||||||
|
decorateRequestsWithArrLinks,
|
||||||
|
decorateDownloadsWithArrLinks
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-22
@@ -3,11 +3,13 @@ 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,
|
||||||
getOmbiInstances
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||||
@@ -236,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 || [])
|
||||||
@@ -264,17 +256,7 @@ async function pollAllServices() {
|
|||||||
// Radarr
|
// Radarr
|
||||||
if (shouldPollRadarr) {
|
if (shouldPollRadarr) {
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, cacheTTL);
|
}, cacheTTL);
|
||||||
cache.set('poll:radarr-history', {
|
cache.set('poll:radarr-history', {
|
||||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||||
|
|||||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
|||||||
expect(result.childNodes.length).toBe(0);
|
expect(result.childNodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('createDownloadCard rendering details', () => {
|
||||||
|
let originalState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalState = { ...state };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset global state
|
||||||
|
Object.assign(state, originalState);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createClientLogo and fallbacks', () => {
|
||||||
|
it('renders client logo img tag when client is configured', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceName: 'Qbit Main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
|
||||||
|
const img = wrapper.querySelector('img.download-client-logo');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||||
|
expect(img.alt).toBe('Qbit Main icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to character avatar text on img load error', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'transmission'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
|
||||||
|
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||||
|
img.onerror();
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||||
|
expect(wrapper.textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createServiceIcons deep-linking', () => {
|
||||||
|
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||||
|
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
ombiLink: 'https://ombi.test/request/42',
|
||||||
|
ombiTooltip: 'View on Ombi'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const ombiLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(ombiLinkEl).toBeTruthy();
|
||||||
|
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||||
|
|
||||||
|
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('View on Ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Sonarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Radarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Blade Runner 2049',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrType: 'radarr',
|
||||||
|
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-movie a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||||
|
state.isAdmin = false; // Non-admin
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/requests.js
|
||||||
|
*
|
||||||
|
* Verifies requests dashboard rendering, tooltips, dates, and deep links.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/state.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
ombiRequests: { movie: [], tv: [] },
|
||||||
|
selectedRequestTypes: ['movie', 'tv'],
|
||||||
|
selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'],
|
||||||
|
requestSortMode: 'requestedDate_desc',
|
||||||
|
requestSearchQuery: '',
|
||||||
|
ombiBaseUrl: 'https://ombi.test',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requests rendering', () => {
|
||||||
|
let requestsList, noRequests;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="requests-list"></div>
|
||||||
|
<div id="no-requests" style="display: none;"><p></p></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
requestsList = document.getElementById('requests-list');
|
||||||
|
noRequests = document.getElementById('no-requests');
|
||||||
|
|
||||||
|
state.ombiRequests = { movie: [], tv: [] };
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiBaseUrl = 'https://ombi.test';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No requests found." when request arrays are empty', () => {
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(0);
|
||||||
|
expect(noRequests.style.display).toBe('block');
|
||||||
|
expect(noRequests.querySelector('p').textContent).toBe('No requests found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders request card with correctly formatted date, media type, and requester', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
year: '2026',
|
||||||
|
requestedUser: { alias: 'john_doe' },
|
||||||
|
requestedDate: '2026-05-27T10:15:30.000Z',
|
||||||
|
quality: '1080p',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.request-title').textContent).toBe('Movie Test');
|
||||||
|
expect(card.querySelector('.request-year').textContent).toBe('2026');
|
||||||
|
expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe');
|
||||||
|
|
||||||
|
// Check formatted date
|
||||||
|
const dateEl = card.querySelector('.request-date');
|
||||||
|
expect(dateEl).toBeTruthy();
|
||||||
|
expect(dateEl.textContent).toContain('Date: 2026-05-27');
|
||||||
|
|
||||||
|
// Check view in Ombi link
|
||||||
|
const ombiLink = card.querySelector('.ombi-link');
|
||||||
|
expect(ombiLink).toBeTruthy();
|
||||||
|
expect(ombiLink.href).toBe('https://ombi.test/details/movie/555');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
title: 'TV Test No User',
|
||||||
|
requestedDate: '2026-05-27T12:00:00.000Z',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
const userEl = card.querySelector('.request-user');
|
||||||
|
expect(userEl).toBeTruthy();
|
||||||
|
expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)');
|
||||||
|
expect(userEl.title).toBe('No user information received from Ombi');
|
||||||
|
expect(userEl.style.textDecoration).toBe('underline dotted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render Sonarr/Radarr deep links for non-admin users', () => {
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.radarr-link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => {
|
||||||
|
state.isAdmin = true;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
title: 'TV Show Test',
|
||||||
|
theMovieDbId: 666,
|
||||||
|
arrLink: 'http://sonarr:8989/series/slug',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(2);
|
||||||
|
|
||||||
|
// Check Radarr link
|
||||||
|
const movieCard = requestsList.childNodes[0];
|
||||||
|
const radarrLink = movieCard.querySelector('.radarr-link');
|
||||||
|
expect(radarrLink).toBeTruthy();
|
||||||
|
expect(radarrLink.href).toBe('http://radarr:7878/movie/slug');
|
||||||
|
expect(radarrLink.title).toBe('View in Radarr');
|
||||||
|
|
||||||
|
// Check Sonarr link
|
||||||
|
const tvCard = requestsList.childNodes[1];
|
||||||
|
const sonarrLink = tvCard.querySelector('.sonarr-link');
|
||||||
|
expect(sonarrLink).toBeTruthy();
|
||||||
|
expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug');
|
||||||
|
expect(sonarrLink.title).toBe('View in Sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/theme.js
|
||||||
|
*
|
||||||
|
* Verifies DOM actions for theme switcher button clicks, attributes, and storage calls.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js';
|
||||||
|
import * as storage from '../../../client/src/utils/storage.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/utils/storage.js', () => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getTheme: vi.fn(() => store.theme || 'light'),
|
||||||
|
saveTheme: vi.fn((theme) => { store.theme = theme; })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('theme switcher', () => {
|
||||||
|
let lightBtn, darkBtn, monoBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
|
||||||
|
// Create mock theme buttons
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
lightBtn = document.querySelector('[data-theme="light"]');
|
||||||
|
darkBtn = document.querySelector('[data-theme="dark"]');
|
||||||
|
monoBtn = document.querySelector('[data-theme="mono"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher sets active class based on saved theme on load', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue('dark');
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(storage.getTheme).toHaveBeenCalled();
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher defaults to light theme if no theme is saved', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue(null);
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking theme button switches the document theme and persists choice', () => {
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
// Initial active button should be light
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
// Click Dark
|
||||||
|
darkBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('dark');
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
|
||||||
|
// Click Mono
|
||||||
|
monoBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTheme directly sets document attribute and updates button classes if present', () => {
|
||||||
|
initThemeSwitcher(); // binds buttons
|
||||||
|
|
||||||
|
setTheme('mono');
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/utils/clientLogCapture.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { initClientLogCapture } from '../../../client/src/utils/clientLogCapture.js';
|
||||||
|
|
||||||
|
describe('clientLogCapture', () => {
|
||||||
|
let fetchMock;
|
||||||
|
let originalConsoleLog;
|
||||||
|
let originalConsoleWarn;
|
||||||
|
let originalConsoleError;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Preserve original console methods
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
originalConsoleWarn = console.warn;
|
||||||
|
originalConsoleError = console.error;
|
||||||
|
|
||||||
|
// Reset console methods to standard ones
|
||||||
|
console.log = vi.fn();
|
||||||
|
console.warn = vi.fn();
|
||||||
|
console.error = vi.fn();
|
||||||
|
|
||||||
|
// Mock window fetch
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.window.fetch = fetchMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
// Restore original console methods
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits early and does not intercept console if status returns disabled', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
await initClientLogCapture();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||||
|
|
||||||
|
// Console calls should NOT be queued or overridden (i.e. they should just run vi.fn mocks)
|
||||||
|
console.log('Test message');
|
||||||
|
expect(console.log).toHaveBeenCalledWith('Test message');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalledWith('/api/debug/client-logs', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hooks console and flushes logs periodically when status returns enabled', async () => {
|
||||||
|
fetchMock.mockImplementation((url, options) => {
|
||||||
|
if (url === '/api/debug/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/debug/client-logs') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await initClientLogCapture();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||||
|
|
||||||
|
// Trigger console logs
|
||||||
|
console.log('Booting app', { config: 'loaded' });
|
||||||
|
console.warn('Deprecated api call');
|
||||||
|
console.error('Failed request', new Error('timeout'));
|
||||||
|
|
||||||
|
// Move timers forward to trigger flush interval (2000ms)
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/client-logs', expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastCall = fetchMock.mock.calls.find(call => call[0] === '/api/debug/client-logs');
|
||||||
|
expect(lastCall).toBeDefined();
|
||||||
|
|
||||||
|
const loggedEntries = JSON.parse(lastCall[1].body);
|
||||||
|
expect(loggedEntries).toHaveLength(4); // Includes the interceptor boot message + 3 logs
|
||||||
|
|
||||||
|
expect(loggedEntries[1].level).toBe('info');
|
||||||
|
expect(loggedEntries[1].message).toContain('Booting app {"config":"loaded"}');
|
||||||
|
|
||||||
|
expect(loggedEntries[2].level).toBe('warn');
|
||||||
|
expect(loggedEntries[2].message).toContain('Deprecated api call');
|
||||||
|
|
||||||
|
expect(loggedEntries[3].level).toBe('error');
|
||||||
|
expect(loggedEntries[3].message).toContain('Failed request');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -225,7 +225,7 @@ function invalidatePollCache() {
|
|||||||
'poll:sab-queue', 'poll:sab-history',
|
'poll:sab-queue', 'poll:sab-history',
|
||||||
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||||
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
||||||
'poll:qbittorrent'
|
'poll:qbittorrent', 'poll:ombi-requests'
|
||||||
];
|
];
|
||||||
for (const k of keys) cache.invalidate(k);
|
for (const k of keys) cache.invalidate(k);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -349,6 +343,7 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.arrQueueId).toBe(1002);
|
expect(dl.arrQueueId).toBe(1002);
|
||||||
expect(dl.arrType).toBe('sonarr');
|
expect(dl.arrType).toBe('sonarr');
|
||||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||||
|
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||||
expect(dl.downloadPath).toBeDefined();
|
expect(dl.downloadPath).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -562,6 +557,47 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.canBlocklist).toBe(true);
|
expect(dl.canBlocklist).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
|
||||||
|
it('decorates active series downloads with Sonarr links for administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
// Seed cache: queue record exists and matches SABnzbd slot
|
||||||
|
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr /api/v3/movie response
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const downloads = res.body.downloads;
|
||||||
|
const dl = downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
|
||||||
|
expect(dl.arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -750,11 +786,13 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
|
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
@@ -763,18 +801,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/permission denied/i);
|
expect(res.body.error).toMatch(/permission denied/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 403 for non-admin when download not found in active downloads', async () => {
|
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return empty array (download not found)
|
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
|
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
@@ -782,7 +816,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/download not found/i);
|
expect(res.body.error).toMatch(/download not found/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
||||||
@@ -790,11 +823,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
|
// Seed cache: queue record with import issues — qualifies non-admin for blocklist
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['Import error 1'] }]
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
// Mock Sonarr DELETE and command endpoints
|
// Mock Sonarr DELETE and command endpoints
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
@@ -812,7 +848,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 when required fields are missing', async () => {
|
it('returns 400 when required fields are missing', async () => {
|
||||||
@@ -843,11 +878,8 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -864,18 +896,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Radarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(RADARR_BASE)
|
nock(RADARR_BASE)
|
||||||
.delete('/api/v3/queue/2001')
|
.delete('/api/v3/queue/2001')
|
||||||
@@ -892,18 +920,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -916,7 +940,90 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.set('X-CSRF-Token', csrf)
|
.set('X-CSRF-Token', csrf)
|
||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
mockGetAllDownloads.mockRestore();
|
});
|
||||||
|
|
||||||
|
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.delete('/api/v3/queue/1001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.delete('/api/v3/queue/1001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
|
||||||
|
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
|
||||||
|
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
|
||||||
|
// Without String() casting the === comparison fails and returns 403.
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
// Seed Radarr queue with a numeric id (as Radarr API returns it)
|
||||||
|
cache.set('poll:radarr-queue', { records: [{
|
||||||
|
id: 9050001,
|
||||||
|
title: 'Project.Hail.Mary.2026.2160p',
|
||||||
|
movieId: 77,
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
_instanceUrl: RADARR_BASE,
|
||||||
|
_instanceKey: 'rk'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.delete('/api/v3/queue/9050001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.post('/api/v3/command')
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
|
||||||
|
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1021,5 +1128,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
|||||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||||
|
const { cookies } = await loginAs(appInstance);
|
||||||
|
const res = await request(appInstance)
|
||||||
|
.get('/api/dashboard/stream')
|
||||||
|
.query({ testClose: 'true' })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('data:');
|
||||||
|
|
||||||
|
const dataStr = text.substring(text.indexOf('{'));
|
||||||
|
const data = JSON.parse(dataStr.trim());
|
||||||
|
|
||||||
|
// Payload Contract Validation
|
||||||
|
expect(data).toHaveProperty('user');
|
||||||
|
expect(data).toHaveProperty('isAdmin');
|
||||||
|
expect(data).toHaveProperty('downloads');
|
||||||
|
expect(data).toHaveProperty('downloadClients');
|
||||||
|
expect(data).toHaveProperty('ombiRequests');
|
||||||
|
expect(data).toHaveProperty('ombiBaseUrl');
|
||||||
|
|
||||||
|
expect(Array.isArray(data.downloads)).toBe(true);
|
||||||
|
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 1. Get the route handler from the dashboard router stack
|
||||||
|
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||||
|
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||||
|
// Get the final handler (after requireAuth middleware)
|
||||||
|
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
// 2. Setup mock req and res
|
||||||
|
const mockUser = { name: 'Alice', isAdmin: false };
|
||||||
|
const reqOnCallbacks = {};
|
||||||
|
const mockReq = {
|
||||||
|
user: mockUser,
|
||||||
|
query: { showAll: 'false', testClose: 'false' },
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
reqOnCallbacks[event] = cb;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const resWrites = [];
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
flushHeaders: vi.fn(),
|
||||||
|
write: vi.fn((data) => {
|
||||||
|
resWrites.push(data);
|
||||||
|
}),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Call the handler
|
||||||
|
await streamHandler(mockReq, mockRes);
|
||||||
|
|
||||||
|
// Initial payload should be written
|
||||||
|
expect(resWrites.length).toBeGreaterThan(0);
|
||||||
|
expect(resWrites[0]).toContain('data:');
|
||||||
|
|
||||||
|
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
|
||||||
|
// Check that heartbeat was written
|
||||||
|
expect(resWrites).toContain(': heartbeat\n\n');
|
||||||
|
|
||||||
|
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||||
|
expect(reqOnCallbacks['close']).toBeDefined();
|
||||||
|
reqOnCallbacks['close']();
|
||||||
|
|
||||||
|
// Check that advancing time again does NOT write another heartbeat
|
||||||
|
const beforeLength = resWrites.length;
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
describe('Debug Logs API Integration', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret-xyz';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
delete process.env.ENABLE_LOG_STREAM;
|
||||||
|
delete process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/debug/status', () => {
|
||||||
|
it('returns enabled: false when ENABLE_LOG_STREAM is not true', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns enabled: true when ENABLE_LOG_STREAM is true', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Global toggle checking', () => {
|
||||||
|
it('returns 403 Forbidden on server logs GET when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/server-logs');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden on client logs GET when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/client-logs');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden on client logs POST when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).post('/api/debug/client-logs').send([]);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subnet CIDR validation', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
process.env.LOG_ALLOW_SUBNETS = '127.0.0.1/32,192.168.1.0/24';
|
||||||
|
process.env.TRUST_PROXY = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden if client IP is not in subnet allowlist', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.50');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/Access denied from IP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bypasses subnet check and hits auth validation if client IP is allowed', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// In subnet allowlist but missing credentials -> returns 401 instead of 403!
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.set('X-Forwarded-For', '192.168.1.150');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication and Bypass policies', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 Unauthorized when all auth options are missing', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/server-logs');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers['www-authenticate']).toContain('Basic realm=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access via X-Webhook-Secret header bypass', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// X-Webhook-Secret bypass avoids Emby login entirely (returns 200 SSE stream)
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs?testClose=true')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access via Basic Authentication with valid Emby administrator credentials', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
// Mock Emby login
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, {
|
||||||
|
AccessToken: 'admin-emby-tok',
|
||||||
|
User: { Id: 'admin-user-id', Name: 'embyadmin' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Emby profile fetch verifying IsAdministrator is true
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/admin-user-id')
|
||||||
|
.reply(200, {
|
||||||
|
Id: 'admin-user-id',
|
||||||
|
Name: 'embyadmin',
|
||||||
|
Policy: { IsAdministrator: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs?testClose=true')
|
||||||
|
.auth('embyadmin', 'password123');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies access via Basic Authentication if user is not an administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, {
|
||||||
|
AccessToken: 'user-emby-tok',
|
||||||
|
User: { Id: 'regular-user-id', Name: 'embyuser' }
|
||||||
|
});
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/regular-user-id')
|
||||||
|
.reply(200, {
|
||||||
|
Id: 'regular-user-id',
|
||||||
|
Name: 'embyuser',
|
||||||
|
Policy: { IsAdministrator: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.auth('embyuser', 'password123');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client logs ingestion and streaming', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 Bad Request on client logs POST if body is not an array', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/debug/client-logs')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||||
|
.send({ message: 'not an array' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingests client logs array and streams them over client logs GET SSE', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
// Ingest client logs
|
||||||
|
const postRes = await request(app)
|
||||||
|
.post('/api/debug/client-logs')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||||
|
.send([
|
||||||
|
{ timestamp: new Date().toISOString(), level: 'info', message: 'Hello from client' }
|
||||||
|
]);
|
||||||
|
expect(postRes.status).toBe(200);
|
||||||
|
expect(postRes.body.count).toBe(1);
|
||||||
|
|
||||||
|
// Verify log streams successfully via GET
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/debug/client-logs?testClose=true')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
expect(getRes.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
//
|
||||||
|
// Integration tests for the torrent matcher's hash-first matching and
|
||||||
|
// arrQueueId deduplication paths (Issue #65). These exercise `matchTorrents`
|
||||||
|
// end-to-end against minimal but realistic queue/history record fixtures.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const DownloadMatcher = require('../../server/services/DownloadMatcher');
|
||||||
|
|
||||||
|
// Build a minimal context. `showAll: true` bypasses per-user tag filtering so
|
||||||
|
// these tests can assert matching behaviour without setting up the Emby user
|
||||||
|
// tag plumbing.
|
||||||
|
function makeContext({
|
||||||
|
sonarrQueueRecords = [],
|
||||||
|
sonarrHistoryRecords = [],
|
||||||
|
radarrQueueRecords = [],
|
||||||
|
radarrHistoryRecords = []
|
||||||
|
} = {}) {
|
||||||
|
const seriesMap = new Map();
|
||||||
|
const moviesMap = new Map();
|
||||||
|
for (const r of sonarrQueueRecords.concat(sonarrHistoryRecords)) {
|
||||||
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
|
for (const r of radarrQueueRecords.concat(radarrHistoryRecords)) {
|
||||||
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sonarrQueueRecords,
|
||||||
|
sonarrHistoryRecords,
|
||||||
|
radarrQueueRecords,
|
||||||
|
radarrHistoryRecords,
|
||||||
|
seriesMap,
|
||||||
|
moviesMap,
|
||||||
|
// null tagMaps so extractAllTags uses the object `label` shape from the
|
||||||
|
// Sonarr fixture (series.tags = [{ label: '...' }]). An empty Map is
|
||||||
|
// truthy and would cause every id-lookup to return undefined.
|
||||||
|
sonarrTagMap: null,
|
||||||
|
radarrTagMap: null,
|
||||||
|
username: 'tester',
|
||||||
|
isAdmin: false,
|
||||||
|
// showAll bypasses per-user tag filtering — we only need it to be truthy
|
||||||
|
// *together* with non-empty allTags. We seed series/movie tags as non-empty
|
||||||
|
// strings (Sonarr tag shape) so `extractAllTags` yields entries.
|
||||||
|
showAll: true,
|
||||||
|
embyUserMap: new Map(),
|
||||||
|
ombiBaseUrl: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesShowA = {
|
||||||
|
id: 100,
|
||||||
|
title: 'Show A',
|
||||||
|
tags: [{ label: 'tester' }],
|
||||||
|
images: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const movieFilmB = {
|
||||||
|
id: 200,
|
||||||
|
title: 'Film B',
|
||||||
|
tags: [{ label: 'tester' }],
|
||||||
|
images: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('matchTorrents — hash-first matching (#65)', () => {
|
||||||
|
it('matches a torrent to a Sonarr queue record by hash even when the title differs', async () => {
|
||||||
|
const hash = 'ABC123HASHsonarr';
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 9001,
|
||||||
|
// Title intentionally bears no resemblance to the torrent name to
|
||||||
|
// prove the match is via hash (downloadId), not title fallback.
|
||||||
|
title: 'totally.unrelated.queue.record',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: hash,
|
||||||
|
episodeId: 555
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
hash,
|
||||||
|
name: 'Show.A.S01.2160p.WEB.x265-RELEASE',
|
||||||
|
progress: 0.5,
|
||||||
|
dlspeed: 1000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('sonarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(9001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a Transmission torrent via hashString', async () => {
|
||||||
|
const hashString = 'TRANSMISSIONHASH456';
|
||||||
|
const context = makeContext({
|
||||||
|
radarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 7777,
|
||||||
|
title: 'unrelated',
|
||||||
|
sourceTitle: '',
|
||||||
|
movieId: 200,
|
||||||
|
movie: movieFilmB,
|
||||||
|
downloadId: hashString
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
hashString,
|
||||||
|
name: 'Film.B.2160p.WEB.x265-RELEASE',
|
||||||
|
progress: 0.25,
|
||||||
|
dlspeed: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('radarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(7777);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to title-substring matching when no hash is present on the torrent', async () => {
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [
|
||||||
|
{
|
||||||
|
id: 555,
|
||||||
|
title: 'Show A',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{
|
||||||
|
// No hash / hashString — title fallback must engage.
|
||||||
|
name: 'Show A — S01E02',
|
||||||
|
progress: 0.7,
|
||||||
|
dlspeed: 5000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrType).toBe('sonarr');
|
||||||
|
expect(out[0].arrQueueId).toBe(555);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchTorrents — arrQueueId deduplication (#65)', () => {
|
||||||
|
it('deduplicates two torrents matching distinct queue records sharing one arrQueueId via the same hash', async () => {
|
||||||
|
// Construct the pathological case the dedup step is designed for: two
|
||||||
|
// torrents (post-hash-match) both end up mapped to the same arrQueueId.
|
||||||
|
// In real life this happens when *arr exposes multiple queue rows under
|
||||||
|
// one logical download. The first matched download wins; subsequent ones
|
||||||
|
// are dropped.
|
||||||
|
const hash = 'PACKHASH001';
|
||||||
|
const sharedQueueRow = {
|
||||||
|
id: 4242, // same arrQueueId
|
||||||
|
title: 'unrelated',
|
||||||
|
sourceTitle: '',
|
||||||
|
seriesId: 100,
|
||||||
|
series: seriesShowA,
|
||||||
|
downloadId: hash
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = makeContext({
|
||||||
|
sonarrQueueRecords: [sharedQueueRow]
|
||||||
|
});
|
||||||
|
|
||||||
|
const torrents = [
|
||||||
|
{ hash, name: 'Show.A.S02E01', progress: 0.1, dlspeed: 0 },
|
||||||
|
{ hash, name: 'Show.A.S02E02', progress: 0.2, dlspeed: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].arrQueueId).toBe(4242);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not deduplicate torrents that lack arrQueueId (no matched *arr record)', async () => {
|
||||||
|
const context = makeContext();
|
||||||
|
const torrents = [
|
||||||
|
{ hash: 'no-match-A', name: 'unmatched-A', progress: 0, dlspeed: 0 },
|
||||||
|
{ hash: 'no-match-B', name: 'unmatched-B', progress: 0, dlspeed: 0 }
|
||||||
|
];
|
||||||
|
const out = await DownloadMatcher.matchTorrents(torrents, context);
|
||||||
|
// Both unmatched torrents are filtered out by the matcher entirely because
|
||||||
|
// there is nothing to match against — so the deduplicator never sees them.
|
||||||
|
// This test simply asserts the dedup step itself does not collapse
|
||||||
|
// non-arr entries into a single bucket when no key is present.
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
@@ -856,7 +898,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 42,
|
id: 42,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
@@ -870,7 +912,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
|
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
|
||||||
expect(res.body.applicationToken).toBe('test-ombi-key');
|
expect(res.body.applicationToken).toBe('test-ombi-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -882,7 +924,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 0,
|
id: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
@@ -1014,10 +1056,16 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
expect(webhookScope.isDone()).toBe(true);
|
expect(webhookScope.isDone()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles webhook send errors gracefully', async () => {
|
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
|
||||||
nock(SOFARR_BASE)
|
nock(SOFARR_BASE)
|
||||||
.post('/api/webhook/ombi')
|
.post('/api/webhook/ombi')
|
||||||
.reply(500, { error: 'Internal server error' });
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
|
||||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
@@ -1029,4 +1077,26 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
|
|
||||||
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to local loopback when public URL request fails', async () => {
|
||||||
|
nock(SOFARR_BASE)
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.replyWithError('Connection refused');
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
|
||||||
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ombi/webhook/test')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||||
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||||
|
|
||||||
|
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||||
|
const RADARR_BASE = 'https://radarr-decor.test';
|
||||||
|
|
||||||
|
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
// Reset the singleton retrievers registry so we can inject our test instances
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// Configure test environment variables for retrievers
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||||
|
// Mock Sonarr series query
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr movie query (empty)
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrSeriesId: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||||
|
expect(downloads[0].arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||||
|
// Mock Sonarr series query (empty)
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
// Mock Radarr movie query with matching ID
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'Blade.Runner.2049.2017.1080p',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrInstanceUrl: RADARR_BASE,
|
||||||
|
arrContentId: 99
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||||
|
expect(downloads[0].arrType).toBe('radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips decoration entirely when isAdmin is false', async () => {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// No nocks are set up, so any HTTP calls would throw or error
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, false);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty downloads array gracefully', async () => {
|
||||||
|
// No mock setups needed, should complete without throwing
|
||||||
|
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||||
|
// Mock Sonarr series query throwing connection error
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
// Mock Radarr movie query throwing timeout error
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No links decorated since the fetch failed
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let originalSkipRateLimit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Save current rate limiting skip flag
|
||||||
|
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||||
|
// Explicitly delete it before loading the app so rate limiters are active
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
|
||||||
|
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||||
|
const appModule = await import('../../server/app.js');
|
||||||
|
const createApp = appModule.createApp;
|
||||||
|
|
||||||
|
// Create a new app instance with rate limiting enabled
|
||||||
|
app = createApp({ skipRateLimits: false });
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore rate limit skip flag
|
||||||
|
if (originalSkipRateLimit !== undefined) {
|
||||||
|
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||||
|
} else {
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||||
|
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||||
|
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||||
|
// count toward the rate limit window of 10 requests.
|
||||||
|
nock('https://emby.test')
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(401, { error: 'Unauthorized' })
|
||||||
|
.persist();
|
||||||
|
|
||||||
|
// Fire 10 rapid failed login requests (the limit is 10)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11th request must be rate limited and return 429
|
||||||
|
const limitRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(limitRes.status).toBe(429);
|
||||||
|
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
|
|||||||
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have Debug logging endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/debug/status']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/status'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 200 for Swagger UI endpoint', async () => {
|
it('should return 200 for Swagger UI endpoint', async () => {
|
||||||
const response = await request(app).get('/api/swagger').redirects(1);
|
const response = await request(app).get('/api/swagger').redirects(1);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ function makeApp() {
|
|||||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||||
]);
|
]);
|
||||||
|
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
||||||
|
]);
|
||||||
return createApp({ skipRateLimits: true });
|
return createApp({ skipRateLimits: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
|
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -125,6 +129,7 @@ afterEach(() => {
|
|||||||
delete process.env.SOFARR_BASE_URL;
|
delete process.env.SOFARR_BASE_URL;
|
||||||
delete process.env.SONARR_INSTANCES;
|
delete process.env.SONARR_INSTANCES;
|
||||||
delete process.env.RADARR_INSTANCES;
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.OMBI_INSTANCES;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
|||||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
|
||||||
|
.send(SONARR_GRAB);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/sonarr?secret=wrong-query-secret')
|
||||||
|
.send(SONARR_GRAB);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/webhook/radarr — secret validation', () => {
|
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||||
@@ -166,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
|||||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
|
||||||
|
.send(RADARR_GRAB);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/radarr?secret=wrong-query-secret')
|
||||||
|
.send(RADARR_GRAB);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -347,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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -518,3 +590,190 @@ describe('GET /api/webhook/config', () => {
|
|||||||
expect(res.body.missing).toHaveLength(2);
|
expect(res.body.missing).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ombi webhook receiver
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('POST /api/webhook/ombi', () => {
|
||||||
|
function postOmbi(app, payload, secret = VALID_SECRET) {
|
||||||
|
const req = request(app).post('/api/webhook/ombi').send(payload);
|
||||||
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
|
||||||
|
.send({
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 127,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'Query Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:40:00.000Z'
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/webhook/ombi?secret=wrong-query-secret')
|
||||||
|
.send({ notificationType: 'NewRequest', requestId: 1 });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { requestId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when notificationType is unknown', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
// Nock requests endpoint since processWebhookEvent will fetch requests
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 123,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:30:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid RequestAvailable event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 124,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'Available Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Available',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:31:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns duplicate: true for a replay of the same event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 125,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:32:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const res1 = await postOmbi(app, payload);
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.duplicate).toBeUndefined();
|
||||||
|
|
||||||
|
// Replay
|
||||||
|
const res2 = await postOmbi(app, payload);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.duplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
NotificationType: 'NewRequest',
|
||||||
|
RequestId: 126,
|
||||||
|
RequestedUser: { UserName: 'gordon_pascal' },
|
||||||
|
Title: 'Pascal Movie',
|
||||||
|
Type: 'Movie',
|
||||||
|
RequestStatus: 'Pending',
|
||||||
|
ApplicationUrl: 'https://ombi.test',
|
||||||
|
RequestedDate: '2026-05-23T20:33:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -183,3 +183,152 @@ describe('arrRetrieverRegistry', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OmbiRetriever._hydrateRequest', () => {
|
||||||
|
let retriever;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
retriever = new OmbiRetriever({
|
||||||
|
id: 'ombi-test',
|
||||||
|
name: 'Test Ombi',
|
||||||
|
url: 'http://localhost:5000',
|
||||||
|
apiKey: 'test-key'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the userMap cache
|
||||||
|
retriever.cache.userMap.set('user-1', {
|
||||||
|
id: 'user-1',
|
||||||
|
userName: 'testuser',
|
||||||
|
alias: 'TestUser',
|
||||||
|
userAlias: 'TestUser',
|
||||||
|
normalizedUserName: 'testuser'
|
||||||
|
});
|
||||||
|
retriever.cache.userMap.set('user-2', {
|
||||||
|
id: 'user-2',
|
||||||
|
userName: 'adminuser',
|
||||||
|
alias: 'AdminUser',
|
||||||
|
userAlias: 'AdminUser',
|
||||||
|
normalizedUserName: 'adminuser'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates top-level requestedUserId', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {}
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.requestedUser.alias).toBe('TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates childRequests requestedUserId (TV requests)', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {},
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-2',
|
||||||
|
requestedUser: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('adminuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes requestedDate from childRequests to top level', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite existing top-level requestedDate', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
requestedDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles PascalCase RequestedDate from childRequests', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
RequestedDate: '2026-06-01T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unmodified request when no hydration needed', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Movie',
|
||||||
|
requestedUser: { userName: 'existing', alias: 'Existing' }
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: null
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: []
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips child hydration when child already has valid requestedUser', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: { userName: 'already_set', alias: 'AlreadySet' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('already_set');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getUsers', () => {
|
||||||
|
it('should return user array for successful request', async () => {
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: '1', userName: 'Gordon' },
|
||||||
|
{ id: '2', userName: 'Alice' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on API error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(500, { error: 'Internal Server Error' });
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on network error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.replyWithError('Network error');
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should refresh if cache is not expired but force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
|
||||||
|
// First refresh
|
||||||
|
await retriever.refreshCache();
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||||
|
|
||||||
|
// Set up new mocks for second refresh without advancing time
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
// Second refresh with force=true should make API calls
|
||||||
|
await retriever.refreshCache(true);
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||||
const mockMovies = [
|
const mockMovies = [
|
||||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||||
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockMovies);
|
expect(result).toEqual(mockMovies);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const result = await retriever.getMovieRequests(true);
|
||||||
|
expect(result).toEqual(mockMovies2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTvRequests', () => {
|
describe('getTvRequests', () => {
|
||||||
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockTvShows);
|
expect(result).toEqual(mockTvShows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies = [];
|
||||||
|
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||||
|
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows1);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows2);
|
||||||
|
|
||||||
|
const result = await retriever.getTvRequests(true);
|
||||||
|
expect(result).toEqual(mockTvShows2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findMovieRequest', () => {
|
describe('findMovieRequest', () => {
|
||||||
@@ -675,4 +766,70 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hydration logic', () => {
|
||||||
|
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser).toBeDefined();
|
||||||
|
expect(result[0].requestedUser.userName).toBe('Gordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('G-Man');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not overwrite non-empty requestedUser object', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Movie 1',
|
||||||
|
requestedUserId: 'gordon-id',
|
||||||
|
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('ExistingG');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ describe('QBittorrentClient', () => {
|
|||||||
downloaded: 750000000,
|
downloaded: 750000000,
|
||||||
speed: 1048576,
|
speed: 1048576,
|
||||||
eta: 3600,
|
eta: 3600,
|
||||||
|
seeds: 0,
|
||||||
|
peers: 0,
|
||||||
category: 'movies',
|
category: 'movies',
|
||||||
tags: ['movie', 'hd'],
|
tags: ['movie', 'hd'],
|
||||||
savePath: '/downloads/test',
|
savePath: '/downloads/test',
|
||||||
@@ -138,6 +140,28 @@ describe('QBittorrentClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => {
|
||||||
|
const torrent = {
|
||||||
|
hash: 'def456',
|
||||||
|
name: 'Swarm Torrent',
|
||||||
|
state: 'downloading',
|
||||||
|
progress: 0.1,
|
||||||
|
size: 1000,
|
||||||
|
completed: 100,
|
||||||
|
dlspeed: 0,
|
||||||
|
eta: 0,
|
||||||
|
num_seeds: 7,
|
||||||
|
num_leechs: 3,
|
||||||
|
// Swarm totals — must NOT be picked up as connected counts
|
||||||
|
num_complete: 200,
|
||||||
|
num_incomplete: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = client.normalizeDownload(torrent);
|
||||||
|
expect(normalized.seeds).toBe(7);
|
||||||
|
expect(normalized.peers).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown torrent states', () => {
|
it('should handle unknown torrent states', () => {
|
||||||
const torrent = {
|
const torrent = {
|
||||||
hash: 'abc123',
|
hash: 'abc123',
|
||||||
|
|||||||
@@ -420,4 +420,68 @@ describe('RTorrentClient', () => {
|
|||||||
expect(status).toBeNull();
|
expect(status).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Null-safety (Issue #68)', () => {
|
||||||
|
it('should return [] when d.multicall2 returns a non-array', async () => {
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, null);
|
||||||
|
});
|
||||||
|
const downloads = await client.getActiveDownloads();
|
||||||
|
expect(downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip malformed individual torrent rows instead of throwing', async () => {
|
||||||
|
const torrents = [
|
||||||
|
// valid row
|
||||||
|
['hashA', 'Name A', 100, 50, 0, 0, 1, 1, 0, '/dl', ''],
|
||||||
|
// malformed row (not an array)
|
||||||
|
'not-an-array',
|
||||||
|
// row with null/undefined fields
|
||||||
|
['hashB', null, null, null, null, null, null, null, null, null, null]
|
||||||
|
];
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, torrents);
|
||||||
|
});
|
||||||
|
const downloads = await client.getActiveDownloads();
|
||||||
|
expect(downloads).toHaveLength(2);
|
||||||
|
expect(downloads[0].id).toBe('hashA');
|
||||||
|
expect(downloads[1].id).toBe('hashB');
|
||||||
|
expect(downloads[1].title).toBe('');
|
||||||
|
expect(downloads[1].size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_extractArrInfo should return {} for non-string filename', () => {
|
||||||
|
expect(client._extractArrInfo(null)).toEqual({});
|
||||||
|
expect(client._extractArrInfo(undefined)).toEqual({});
|
||||||
|
expect(client._extractArrInfo(123)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastError tracking (Issue #68)', () => {
|
||||||
|
it('should record lastError on getActiveDownloads failure', async () => {
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(new Error('boom'));
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
expect(client.getLastError().operation).toBe('getActiveDownloads');
|
||||||
|
expect(client.getLastError().message).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear lastError on successful call', async () => {
|
||||||
|
// First, fail.
|
||||||
|
mockMethodCall.mockImplementationOnce((method, params, callback) => {
|
||||||
|
callback(new Error('boom'));
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
|
||||||
|
// Then, succeed.
|
||||||
|
mockMethodCall.mockImplementation((method, params, callback) => {
|
||||||
|
callback(null, []);
|
||||||
|
});
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -299,4 +299,63 @@ describe('SABnzbdClient', () => {
|
|||||||
expect(status).toBeNull();
|
expect(status).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('History limit configuration (Issue #68)', () => {
|
||||||
|
const ORIG_ENV = process.env.SAB_HISTORY_LIMIT;
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIG_ENV === undefined) delete process.env.SAB_HISTORY_LIMIT;
|
||||||
|
else process.env.SAB_HISTORY_LIMIT = ORIG_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults historyLimit to 10 when SAB_HISTORY_LIMIT is unset', () => {
|
||||||
|
delete process.env.SAB_HISTORY_LIMIT;
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors SAB_HISTORY_LIMIT when set to a valid integer', () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = '25';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default on invalid SAB_HISTORY_LIMIT', () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = 'not-a-number';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
expect(c.historyLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes historyLimit through to the history API call', async () => {
|
||||||
|
process.env.SAB_HISTORY_LIMIT = '42';
|
||||||
|
const c = new SABnzbdClient(mockConfig);
|
||||||
|
const makeRequest = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
||||||
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
||||||
|
c.makeRequest = makeRequest;
|
||||||
|
await c.getActiveDownloads();
|
||||||
|
expect(makeRequest).toHaveBeenCalledWith({ mode: 'history', limit: 42 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastError tracking (Issue #68)', () => {
|
||||||
|
it('records lastError when getActiveDownloads fails', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
expect(client.getLastError().operation).toBe('getActiveDownloads');
|
||||||
|
expect(client.getLastError().message).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears lastError after a subsequent successful call', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).not.toBeNull();
|
||||||
|
|
||||||
|
client.makeRequest = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ data: { queue: { slots: [], kbpersec: 0 } } })
|
||||||
|
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
||||||
|
await client.getActiveDownloads();
|
||||||
|
expect(client.getLastError()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ describe('TransmissionClient', () => {
|
|||||||
4: 'Downloading',
|
4: 'Downloading',
|
||||||
5: 'Queued',
|
5: 'Queued',
|
||||||
6: 'Seeding',
|
6: 'Seeding',
|
||||||
7: 'Unknown'
|
// Issue #63: code 7 is undocumented in the RPC spec; mapped to
|
||||||
|
// `Checking` (legacy alias for code 2) as a best-effort interpretation.
|
||||||
|
7: 'Checking'
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
|
Object.entries(statusMap).forEach(([status, expectedStatus]) => {
|
||||||
@@ -433,4 +435,42 @@ describe('TransmissionClient', () => {
|
|||||||
expect(normalized.arrType).toBeUndefined();
|
expect(normalized.arrType).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Torrent Control Methods (Issue #63)', () => {
|
||||||
|
it('startTorrent invokes torrent-start RPC with ids array', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.startTorrent('abc123');
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: ['abc123'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startTorrent accepts an array of ids', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.startTorrent([1, 2, 3]);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: [1, 2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopTorrent invokes torrent-stop RPC', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.stopTorrent(42);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-stop', { ids: [42] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeTorrent invokes torrent-remove with delete-local-data=false by default', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.removeTorrent('hashX');
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
|
||||||
|
ids: ['hashX'],
|
||||||
|
'delete-local-data': false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeTorrent passes delete-local-data=true when requested', async () => {
|
||||||
|
client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } });
|
||||||
|
await client.removeTorrent('hashY', true);
|
||||||
|
expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', {
|
||||||
|
ids: ['hashY'],
|
||||||
|
'delete-local-data': true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -310,7 +310,8 @@ describe('DownloadClientRegistry', () => {
|
|||||||
instanceId: 'sab1',
|
instanceId: 'sab1',
|
||||||
instanceName: 'SAB 1',
|
instanceName: 'SAB 1',
|
||||||
clientType: 'sabnzbd',
|
clientType: 'sabnzbd',
|
||||||
status: { status: 'active' }
|
status: { status: 'active' },
|
||||||
|
lastError: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,40 @@ describe('getRequestStatus', () => {
|
|||||||
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||||
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns available from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns denied from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns approved from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows priority inside childRequests: available > denied > approved > pending', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ available: true, denied: true },
|
||||||
|
{ approved: true }
|
||||||
|
]})).toBe('available');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ denied: true, approved: true },
|
||||||
|
{ requested: true }
|
||||||
|
]})).toBe('denied');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ approved: true, requested: true }
|
||||||
|
]})).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for TV request with empty childRequests', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [] })).toBe('unknown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -79,6 +79,85 @@ describe('ombiHelpers', () => {
|
|||||||
};
|
};
|
||||||
expect(extractRequestedUser(req)).toBe('');
|
expect(extractRequestedUser(req)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns userName from nested user object', () => {
|
||||||
|
const req = { user: { userName: 'user_val' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('user_val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alias from nested requestedBy object', () => {
|
||||||
|
const req = { requestedBy: { alias: 'req_alias' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('req_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalizedUserName from nested ombiUser object', () => {
|
||||||
|
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('norm_ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns userAlias from nested requestedByUser object', () => {
|
||||||
|
const req = { requestedByUser: { userAlias: 'alias_user' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('alias_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from a string source value', () => {
|
||||||
|
const req = { requestedBy: 'direct_string' };
|
||||||
|
expect(extractRequestedUser(req)).toBe('direct_string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
|
||||||
|
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
|
||||||
|
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
|
||||||
|
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from seasons array requests', () => {
|
||||||
|
const req = {
|
||||||
|
seasons: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { alias: 'season_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('season_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests array', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ user: { userName: 'child_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('child_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { userName: 'tv_user', alias: 'tv_alias' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('tv_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser as string', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedUser: 'string_user' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('string_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedByAlias: 'deep_alias' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('deep_alias');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterRequestsByUser', () => {
|
describe('filterRequestsByUser', () => {
|
||||||
|
|||||||
@@ -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,195 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
// Tests for the shared `buildArrQueueCache` helper (Issue #61).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { buildArrQueueCache } = require('../../../server/utils/arrQueueHelpers');
|
||||||
|
|
||||||
|
const sonarrInstances = [
|
||||||
|
{ id: 's1', url: 'http://sonarr-1', apiKey: 'KEY_S1', name: 'Sonarr 1' }
|
||||||
|
];
|
||||||
|
const radarrInstances = [
|
||||||
|
{ id: 'r1', url: 'http://radarr-1', apiKey: 'KEY_R1', name: 'Radarr 1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildArrQueueCache', () => {
|
||||||
|
it('returns empty array for empty / missing input', () => {
|
||||||
|
expect(buildArrQueueCache([], sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(null, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(undefined, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for invalid mediaKey', () => {
|
||||||
|
const queues = [{ instance: 's1', data: { records: [{ id: 1 }] } }];
|
||||||
|
expect(buildArrQueueCache(queues, sonarrInstances, 'bogus')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Sonarr records with _instanceUrl/_instanceKey and decorates embedded series', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dl-1', series: { id: 100, title: 'X' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_S1');
|
||||||
|
expect(out[0].series._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Radarr records and decorates embedded movie', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 11, downloadId: 'dl-r1', movie: { id: 555, title: 'M' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://radarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_R1');
|
||||||
|
expect(out[0].movie._instanceUrl).toBe('http://radarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotates Sonarr season pack records (multiple entries sharing downloadId)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'pack-A', episodeId: 101 },
|
||||||
|
{ id: 2, downloadId: 'pack-A', episodeId: 102 },
|
||||||
|
{ id: 3, downloadId: 'pack-A', episodeId: 103 },
|
||||||
|
{ id: 4, downloadId: 'single-B', episodeId: 200 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(4);
|
||||||
|
const packMembers = out.filter(r => r.downloadId === 'pack-A');
|
||||||
|
expect(packMembers).toHaveLength(3);
|
||||||
|
for (const r of packMembers) {
|
||||||
|
expect(r.isSeasonPack).toBe(true);
|
||||||
|
expect(r.episodeCount).toBe(3);
|
||||||
|
}
|
||||||
|
const single = out.find(r => r.downloadId === 'single-B');
|
||||||
|
expect(single.isSeasonPack).toBeUndefined();
|
||||||
|
expect(single.episodeCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not annotate Radarr records as season packs even if downloadId repeats', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dup', movie: { id: 1 } },
|
||||||
|
{ id: 2, downloadId: 'dup', movie: { id: 2 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
for (const r of out) {
|
||||||
|
expect(r.isSeasonPack).toBeUndefined();
|
||||||
|
expect(r.episodeCount).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed records and continues', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
null,
|
||||||
|
{ id: 1, downloadId: 'ok', series: { id: 1 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ instance: 's1' } // no data property
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown instance id gracefully (null url/key)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'unknown-instance',
|
||||||
|
data: { records: [{ id: 1, downloadId: 'x' }] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBeNull();
|
||||||
|
expect(out[0]._instanceKey).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Set environment variables before requiring any modules
|
||||||
|
process.env.POLL_INTERVAL = '5000';
|
||||||
|
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||||
|
|
||||||
|
const cache = require('../../../server/utils/cache.js');
|
||||||
|
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||||
|
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||||
|
const config = require('../../../server/utils/config.js');
|
||||||
|
|
||||||
|
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||||
|
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||||
|
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||||
|
sabnzbd: [],
|
||||||
|
qbittorrent: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||||
|
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||||
|
movie: [],
|
||||||
|
tv: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||||
|
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||||
|
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||||
|
|
||||||
|
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||||
|
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||||
|
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||||
|
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: null
|
||||||
|
});
|
||||||
|
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Now require the poller
|
||||||
|
const poller = require('../../../server/utils/poller.js');
|
||||||
|
|
||||||
|
describe('Background Poller Utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-apply standard resolved values
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||||
|
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||||
|
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||||
|
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getOmbiInstancesSpy.mockReturnValue([]);
|
||||||
|
|
||||||
|
cacheSetSpy.mockImplementation(() => {});
|
||||||
|
cacheGetSpy.mockReturnValue(null);
|
||||||
|
getWebhookMetricsSpy.mockReturnValue(null);
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||||
|
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
poller.stopPoller();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Poller Core Logic', () => {
|
||||||
|
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||||
|
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||||
|
expect(poller.POLLING_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||||
|
let callbackFired = false;
|
||||||
|
const callback = () => {
|
||||||
|
callbackFired = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.onPollComplete(callback);
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(callbackFired).toBe(true);
|
||||||
|
|
||||||
|
// Clean up/Deregister callback
|
||||||
|
poller.offPollComplete(callback);
|
||||||
|
callbackFired = false;
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(callbackFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||||
|
// Stub initializeClients to delay using a promise
|
||||||
|
let resolveInit;
|
||||||
|
const delayPromise = new Promise((resolve) => {
|
||||||
|
resolveInit = resolve;
|
||||||
|
});
|
||||||
|
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||||
|
|
||||||
|
// Start the first poll (which remains pending on initializeClients)
|
||||||
|
const firstPollPromise = poller.pollAllServices();
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Trigger second poll immediately while first is in progress
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||||
|
|
||||||
|
// Resolve the delay to let the first poll finish
|
||||||
|
resolveInit();
|
||||||
|
await firstPollPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||||
|
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||||
|
|
||||||
|
// Setup error spy
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||||
|
|
||||||
|
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Webhook-Based Instance Bypassing', () => {
|
||||||
|
it('skips polling for an instance with recent active webhook events', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Verify that skips are incremented for both
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||||
|
|
||||||
|
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||||
|
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||||
|
const staleTimestamp = Date.now() - 11 * 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Should bypass the skip and perform a full poll
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent metrics on individual level but stale globally
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global webhook is stale
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
|
||||||
|
// Triggered immediately on start (flush microtasks)
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time by 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance by another 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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