Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aca9c45e2 | |||
| 5d0da45e10 | |||
| adbb0c12c1 | |||
| 9a4408e797 | |||
| 05cf5a0993 | |||
| bb10cd4aef | |||
| 251c10f08c | |||
| 474ae949a9 | |||
| 084cb0579e | |||
| 93a09e10a8 | |||
| 47817d057b | |||
| f6ad7c85bf | |||
| a349c8e2cf | |||
| f2b44f65af | |||
| b3664747cb | |||
| 14b47ce410 | |||
| 5ec5484b91 | |||
| 05c9527189 | |||
| f461c3669c | |||
| 0acd452ebd | |||
| dbdfe3f329 | |||
| b9b5d7d393 | |||
| 7424e70ea6 | |||
| 830dea3d6b | |||
| 4ff462b7f4 | |||
| d9f1fc99a9 | |||
| 46b42045f1 | |||
| d12356e8f3 | |||
| 6124ec0f5a | |||
| fd303699db | |||
| 8f19da3ae6 | |||
| 3c9dd3ca62 | |||
| f02c30efde | |||
| ddebe96056 | |||
| b6367076f9 | |||
| 31ed9f02b6 | |||
| 3e6af1bff2 | |||
| d9897ff0d2 | |||
| 06442c1d75 | |||
| 86aaa79339 | |||
| e2a71e65a1 | |||
| d03efbf25e | |||
| 0b91152ad7 | |||
| 8dc105ff3e | |||
| a38fc4a8ce | |||
| 2bf4cb2a0f | |||
| d74b46d5b0 | |||
| 9cffb96f29 | |||
| 4d61dd566f | |||
| d568800942 | |||
| 7d3e6e6a47 | |||
| ee2f275501 | |||
| ca6ff66115 | |||
| 080431c4b7 | |||
| f457a708d2 | |||
| 914ab73d4e | |||
| 25d8e007a4 | |||
| bb7b66e06d | |||
| 5ad525a760 | |||
| 1e162381f4 | |||
| 42f0481a9a | |||
| ddad80a666 | |||
| e772001c3f | |||
| 1f10414498 | |||
| 1e3926b206 | |||
| 5fde69fcf5 | |||
| a562cfe9aa | |||
| 8549746721 | |||
| 63fc370262 | |||
| 6362441dd5 | |||
| 76f9e87b44 | |||
| 8c461de72a | |||
| d11f11be69 | |||
| 05d11975e6 | |||
| cd3480c0ce | |||
| 712c98d817 | |||
| ff7ace9f4f | |||
| 73500751a0 | |||
| 82a9df134b | |||
| 67fa79796b | |||
| f06d945358 | |||
| f5883d4929 | |||
| 80cf3eaa39 | |||
| 1ab7e52167 | |||
| 544c168b82 | |||
| 747a14ebd3 | |||
| 49d66c07ee | |||
| be791ed044 | |||
| 7195a09562 | |||
| 720de6688b | |||
| 3e06bdf8cd | |||
| ca1c136d4f | |||
| a04f2c9b25 | |||
| 743b169989 | |||
| 794cb7268e | |||
| d310d101ed | |||
| 96f24eb3b7 | |||
| abcb9bfded | |||
| e5920b207f | |||
| d3483f3be7 | |||
| 252cc50aa4 | |||
| 57908e2b9e | |||
| e2757768c7 | |||
| 2469c3e3f4 | |||
| 6c8c333c6a | |||
| 5dfe0b1216 | |||
| 77beef787f | |||
| 235a866ec8 | |||
| f1d9de2a92 | |||
| 9d0e31ec9a | |||
| 42c3eebf18 | |||
| f295e1c90d | |||
| c5e8281440 | |||
| f22dd0d1f6 | |||
| 5159a83475 | |||
| ccc3b6ffec | |||
| 4ec7d734b8 | |||
| 2e85fae57a | |||
| aeacadbe68 | |||
| 3ef35a8c43 | |||
| 0f3c02e52d | |||
| 9fd60bcfed | |||
| af58e1bf2a | |||
| 2d04402284 | |||
| 0310f10e5d | |||
| 5ab8cc96a3 | |||
| a7363fcb3a | |||
| d06e24dbb6 | |||
| 6df94e5ad2 | |||
| 015e07ae7a | |||
| eeab314a08 | |||
| 603f444c33 | |||
| 740b03ac85 | |||
| 917939a9fc | |||
| 575688dab7 | |||
| 3747dab36f | |||
| 76f0aad453 | |||
| 67ab378d31 | |||
| 1bef14d590 | |||
| 8609f03c5a | |||
| fcb0cd8e4a | |||
| 80e8b72878 | |||
| e022db8ef5 | |||
| 1d61ea8d83 | |||
| 99ddb05dbe | |||
| 934f5e3fd5 | |||
| 21befa5356 | |||
| 6e199925aa | |||
| 627329df2f |
+3
-1
@@ -1,3 +1,4 @@
|
||||
# Docker build context ignores
|
||||
node_modules/
|
||||
.env
|
||||
.env.example
|
||||
@@ -7,7 +8,8 @@ node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
client/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
|
||||
+35
@@ -19,6 +19,36 @@ LOG_LEVEL=info
|
||||
# Generate with: openssl rand -hex 32
|
||||
COOKIE_SECRET=your-cookie-secret-here
|
||||
|
||||
# =============================================================================
|
||||
# WEBHOOK SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Secret for validating incoming webhooks from Sonarr and Radarr
|
||||
# Required for webhook endpoints to accept requests
|
||||
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
|
||||
# Generate with: openssl rand -hex 32
|
||||
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
||||
|
||||
# Public base URL of Sofarr (for webhook configuration)
|
||||
# Required for the one-click webhook setup endpoints
|
||||
# Sonarr/Radarr need this URL to know where to send webhook events
|
||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||
SOFARR_BASE_URL=https://your-sofarr-url
|
||||
|
||||
# --- Webhook Polling Optimization (Phase 5) ---
|
||||
|
||||
# Minutes of silence after which the poller falls back to a full poll
|
||||
# even if webhooks were recently active. Default: 10 minutes.
|
||||
# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to
|
||||
# reduce background polling on very stable setups.
|
||||
# WEBHOOK_FALLBACK_TIMEOUT=10
|
||||
|
||||
# When an instance has received a recent webhook event, the poller skips
|
||||
# its queue/history fetch entirely (saving API calls). If you still want
|
||||
# a periodic poll even with webhooks, set this to 1 to disable skipping.
|
||||
# Default behaviour: skip polling for instances with recent webhook activity.
|
||||
# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3
|
||||
|
||||
# =============================================================================
|
||||
# TLS / HTTPS
|
||||
# =============================================================================
|
||||
@@ -136,4 +166,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
||||
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
||||
# 6. Background polling keeps data fresh; disable it for low-resource setups
|
||||
# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time
|
||||
# push updates from Sonarr/Radarr and automatically reduce polling load.
|
||||
# Use the Webhooks Configuration panel in the dashboard UI to enable them
|
||||
# with one click. The secret must match the header value in each *arr
|
||||
# notification connection (X-Sofarr-Webhook-Secret).
|
||||
# =============================================================================
|
||||
|
||||
@@ -40,10 +40,21 @@ jobs:
|
||||
|
||||
- name: Check licence compatibility
|
||||
run: |
|
||||
npx --yes license-checker --production \
|
||||
# First, output all production licenses for visibility
|
||||
echo "Checking production dependency licenses..."
|
||||
npx --yes license-checker --production --excludePrivatePackages --json > /tmp/licenses.json
|
||||
|
||||
# Check for incompatible licenses
|
||||
if ! npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--excludePrivatePackages \
|
||||
&& echo "All production dependency licences are compatible with MIT."
|
||||
--excludePrivatePackages; then
|
||||
echo ""
|
||||
echo "❌ Found incompatible licenses. Full license report:"
|
||||
cat /tmp/licenses.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All production dependency licences are compatible with MIT."
|
||||
|
||||
- name: Check copyright headers in source files
|
||||
run: |
|
||||
@@ -56,6 +67,7 @@ jobs:
|
||||
! -path "./.git/*" \
|
||||
! -path "./dist/*" \
|
||||
! -path "./build/*" \
|
||||
! -path "./public/*" \
|
||||
! -path "./.gitea/*")
|
||||
|
||||
MISSING_HEADER=0
|
||||
@@ -70,6 +82,9 @@ jobs:
|
||||
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
|
||||
echo "❌ Missing MIT-compliant copyright header in: $file"
|
||||
echo " Required format: // Copyright (c) YYYY Name. MIT License."
|
||||
echo " Actual first 5 lines:"
|
||||
head -n 5 "$file" | sed 's/^/ /'
|
||||
echo ""
|
||||
MISSING_HEADER=$((MISSING_HEADER + 1))
|
||||
fi
|
||||
done <<< "$SOURCE_FILES"
|
||||
|
||||
+1085
File diff suppressed because it is too large
Load Diff
+172
@@ -6,6 +6,178 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Staged history loading with SSE push** — history data from Sonarr/Radarr is now fetched in stages; `history-update` SSE events push incremental results to the dashboard so the "Recently Completed" tab populates without waiting for the full fetch.
|
||||
- **Frontend unit tests** — added Vitest + jsdom test suite covering `client/src/` modules (formatters, storage, state).
|
||||
- **Comprehensive tests for staged history loading** — backend tests verify the new background-fetch behaviour, cache TTL handling, and SSE emission.
|
||||
- **Integration test coverage** — new integration and unit tests for `dashboard`, `emby`, `sonarr`, `radarr`, and `sabnzbd` routes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Technical-debt remediation — service extraction** — extracted matching and assembly logic from the monolithic `dashboard.js` (~1,360 lines → ~284 lines) into dedicated, testable services:
|
||||
- `DownloadMatcher.js` — SABnzbd slot / torrent → *arr record matching
|
||||
- `DownloadAssembler.js` — cover-art, episode, link, blocklist-eligibility helpers
|
||||
- `DownloadBuilder.js` — orchestrator that reads the cache snapshot and builds the final user-facing download list
|
||||
- `TagMatcher.js` — tag extraction, sanitisation, and user-ownership matching
|
||||
- `WebhookStatus.js` — webhook configuration status aggregation
|
||||
- **Frontend architecture** — migrated from a monolithic `public/app.js` to vanilla ES modules under `client/src/`, bundled by Vite into `public/app.js`.
|
||||
- **History pagination** — replaced custom date-based cursor pagination with the retriever's built-in pagination (`pageSize=100`, up to 10 pages / 1,000 records total). Eliminates the 40-second response-time regression seen with large histories.
|
||||
- **Status endpoint path** — admin status route moved from `/api/status/status` to `/api/status` for consistency with the router mount point.
|
||||
- **Background-fetch safety** — the poller no longer overwrites the cache with empty data when a background history fetch fails or returns no records.
|
||||
- **SABnzbd progress calculation** — progress is now computed from `slot.mb` and `slot.mbleft` / `slot.mbmissing` because the SABnzbd queue API does not expose a `percentage` field.
|
||||
- **Speed formatting consistency** — `updateDownloadCard()` now calls `formatSpeed()` so all speed values display with uniform units (B/s, KB/s, MB/s, GB/s).
|
||||
- **Status-panel error handling** — the panel now surfaces error messages (e.g. `403` for non-admin users) instead of showing a blank box.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CSRF token reference errors** — fixed `ReferenceError` bugs in `api.js`, `auth.js`, and the blocklist handler where `csrfToken` and `currentUser` were accessed as bare variables instead of via the shared `state` object.
|
||||
- **Logout button** — fixed undefined variable references in `handleLogoutClick` that prevented the logout flow from completing.
|
||||
- **Missing progress bar for SABnzbd** — SABnzbd downloads now render a correct progress bar instead of showing `undefined%`.
|
||||
- **Status route 404** — corrected the Express router mount so `GET /api/status` responds instead of returning HTML 404.
|
||||
- **Status button DOM ID** — fixed element-ID mismatch (`status-btn` vs `status-toggle`) that prevented the admin status button from toggling the panel.
|
||||
- **Tab selection** — fixed tab switching to use `data-tab` attributes after the DOM IDs were removed during the ES-module migration.
|
||||
- **CSP violations and `ignoreAvailable` reference error** — resolved frontend CSP compliance issues and a missing-variable error in the history tab.
|
||||
- **Docker client-build stage** — removed `client/` from `.dockerignore` so the multi-stage Dockerfile can run the Vite build during image creation.
|
||||
- **Unmatched torrent exclusion** — torrents that cannot be matched to a Sonarr/Radarr record are now correctly omitted from the download display.
|
||||
- **Blocklist button CSRF** — fixed a `ReferenceError` that occurred when a non-admin user clicked the blocklist button.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **Unmatched torrents are no longer displayed** — torrents that do not match a Sonarr/Radarr queue or history record are excluded from the dashboard. Users who previously saw direct torrent-client downloads will no longer see them unless the *arr services track the item.
|
||||
- **Frontend build process changed** — the monolithic `public/app.js` source file has been replaced by a Vite build from `client/src/`. Any custom deployment scripts that copy or modify `public/app.js` directly must be updated to run `npm run build` (or use the provided Dockerfile, which already does this).
|
||||
- **Status API endpoint path changed** — the admin status endpoint moved from `/api/status/status` to `/api/status`. External integrations, monitoring checks, or scripts hitting the old path will receive `404`.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.5] - 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
|
||||
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
|
||||
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
|
||||
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
|
||||
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
|
||||
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
|
||||
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.4] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
|
||||
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
|
||||
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
|
||||
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
|
||||
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.3] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
|
||||
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
|
||||
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
|
||||
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.2] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.1] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0a] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Status panel close button** — the `×` button now correctly hides the status panel and stops the auto-refresh timer. The button was previously using an inline `onclick` attribute which was silently blocked by the server's CSP nonce policy. Replaced with `addEventListener` wired after `innerHTML` is set, consistent with all other button handlers in the application.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-05-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline.
|
||||
- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
#### Webhook Integration (Phases 1–5.1)
|
||||
|
||||
- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure.
|
||||
- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle.
|
||||
- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser.
|
||||
- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement.
|
||||
- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event.
|
||||
- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active.
|
||||
- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`.
|
||||
- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events.
|
||||
|
||||
#### Smart Polling Optimization (Phase 5)
|
||||
|
||||
- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`.
|
||||
- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead.
|
||||
- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`.
|
||||
- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped.
|
||||
- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`).
|
||||
- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`).
|
||||
- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance.
|
||||
|
||||
#### Security Hardening (Phase 6)
|
||||
|
||||
- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`.
|
||||
- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message.
|
||||
- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast.
|
||||
- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions.
|
||||
|
||||
#### Documentation (Phase 6)
|
||||
|
||||
- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count.
|
||||
- **`CHANGELOG.md`** — this entry.
|
||||
- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints.
|
||||
- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow.
|
||||
- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section.
|
||||
|
||||
### Changed
|
||||
|
||||
- `poller.js` — `pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data.
|
||||
- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton.
|
||||
- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware.
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
+13
@@ -9,6 +9,18 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1.5 — client-build: build frontend with Vite
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS client-build
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime image (minimal attack surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -33,6 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy application source owned by root (read-only at runtime)
|
||||
COPY --chown=root:root server/ ./server/
|
||||
COPY --chown=root:root public/ ./public/
|
||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
||||
COPY --chown=root:root package.json ./
|
||||
# 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.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
|
||||
|
||||
Version 1.5.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
||||
|
||||
## What It Does
|
||||
|
||||
sofarr connects to your media stack and shows you a personalized view of:
|
||||
@@ -12,27 +14,59 @@ sofarr connects to your media stack and shows you a personalized view of:
|
||||
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
|
||||
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
||||
- **Multi-Instance Support** - Connect to multiple instances of each service
|
||||
- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import
|
||||
- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback
|
||||
|
||||
## How It Works
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
|
||||
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │
|
||||
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
|
||||
└─────────────┘ └──────────────┘ │ Transmission (Torrents) │
|
||||
│ │ rTorrent (Torrents) │
|
||||
│ │ Sonarr (TV management) │
|
||||
│ │ Radarr (Movie management) │
|
||||
│ │ Emby (User authentication) │
|
||||
▼ └─────────────────────────────┘
|
||||
┌──────────────┐
|
||||
│ Dashboard │
|
||||
│ Aggregator │
|
||||
└──────────────┘
|
||||
┌─────────────┐ ┌──────────────────────────────────────────────┐
|
||||
│ Browser │────▶│ sofarr Server │
|
||||
│ (User) │◀────│ Auth · Dashboard · History · Webhooks │
|
||||
└─────────────┘ │ │
|
||||
SSE push ◀───────│ Poller (smart: skips when webhooks active) │
|
||||
│ Cache · PDCA Download Registry · PALDRA │
|
||||
└───┬─────────────────────────┬────────────────┘
|
||||
│ polls (background) │ receives webhooks
|
||||
▼ │
|
||||
┌──────────────────────────┐ ┌─────────▼───────────────────┐
|
||||
│ Download Clients │ │ *arr Services │
|
||||
│ SABnzbd (Usenet) │ │ Sonarr (TV management) │
|
||||
│ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │
|
||||
│ Transmission (Torrent) │ └─────────────────────────────┘
|
||||
│ rTorrent (Torrent) │
|
||||
└──────────────────────────┘
|
||||
│
|
||||
Emby / Jellyfin
|
||||
(User authentication)
|
||||
```
|
||||
|
||||
**Three pluggable layers power sofarr:**
|
||||
|
||||
| Layer | Name | What it does |
|
||||
|-------|------|--------------|
|
||||
| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients |
|
||||
| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances |
|
||||
| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s |
|
||||
|
||||
### Webhooks
|
||||
|
||||
When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay.
|
||||
|
||||
**Quick setup:**
|
||||
1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env`
|
||||
2. Open the sofarr dashboard → **Webhooks Configuration** panel
|
||||
3. Click **Enable** next to each Sonarr/Radarr instance
|
||||
4. sofarr auto-configures the notification connection inside each *arr service
|
||||
|
||||
**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically.
|
||||
|
||||
**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`):
|
||||
- `POST /api/webhook/sonarr` — receives Sonarr events
|
||||
- `POST /api/webhook/radarr` — receives Radarr events
|
||||
|
||||
### The Matching Process
|
||||
|
||||
1. **User Authentication**: Login via Emby credentials
|
||||
@@ -194,6 +228,17 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
```
|
||||
|
||||
### Webhooks & Smart Polling
|
||||
```bash
|
||||
# Required for webhook endpoints to accept events
|
||||
SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32)
|
||||
SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup
|
||||
|
||||
# Optional tuning
|
||||
WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10)
|
||||
WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3)
|
||||
```
|
||||
|
||||
### Download Clients (PDCA)
|
||||
|
||||
sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management.
|
||||
@@ -327,6 +372,20 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
### History
|
||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||
|
||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
||||
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
||||
|
||||
### Webhook Management (requires auth + CSRF)
|
||||
- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections
|
||||
- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection
|
||||
- `GET /api/radarr/api/v3/notification` — list Radarr notification connections
|
||||
- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection
|
||||
- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr
|
||||
- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr
|
||||
- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event
|
||||
- `POST /api/radarr/webhook/test` — trigger a Radarr test event
|
||||
|
||||
### Service APIs (proxy to your services)
|
||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||
- `GET /api/sonarr/*` — Sonarr API proxy
|
||||
@@ -370,7 +429,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
||||
npm run test:ui # interactive Vitest UI
|
||||
```
|
||||
|
||||
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+17
-1
@@ -4,8 +4,10 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.4.x | ✅ Yes |
|
||||
| 1.3.x | ✅ Yes |
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.1.x | ✅ Yes |
|
||||
| 1.1.x | ❌ No |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
@@ -35,6 +37,10 @@ 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 |
|
||||
| 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 |
|
||||
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
|
||||
| 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 flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +55,15 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
- [ ] HTTPS enforced by the reverse proxy with a valid certificate
|
||||
- [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed
|
||||
|
||||
### Webhook-Specific (if using webhook integration)
|
||||
|
||||
- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`)
|
||||
- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup)
|
||||
- [ ] Secret stored only in `.env` or Docker secret — never committed to source control
|
||||
- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI
|
||||
- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header
|
||||
- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing
|
||||
|
||||
### Recommended
|
||||
|
||||
- [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination
|
||||
@@ -145,6 +160,7 @@ server {
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f0f0f0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #c33;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.downloads-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.downloads-container h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.no-downloads {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-downloads p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.downloads-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.download-cover {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.download-cover img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.download-card.series {
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.download-card.movie {
|
||||
border-left: 4px solid #f093fb;
|
||||
}
|
||||
|
||||
.download-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.download-type {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-type.series {
|
||||
background: #e8eaf6;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.download-type.movie {
|
||||
background: #fce4ec;
|
||||
color: #f093fb;
|
||||
}
|
||||
|
||||
.download-status {
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.download-status.downloading {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.download-status.completed {
|
||||
background: #e3f2fd;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.download-status.failed {
|
||||
background: #ffebee;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.download-title {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.download-series,
|
||||
.download-movie {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.download-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [downloads, setDownloads] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/emby/sessions');
|
||||
setSessions(response.data);
|
||||
|
||||
// Auto-select first active session
|
||||
const activeSession = response.data.find(s => s.NowPlayingItem || s.Active);
|
||||
if (activeSession) {
|
||||
setSessionId(activeSession.Id);
|
||||
fetchUserDownloads(activeSession.Id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch Emby sessions. Make sure Emby is running and configured.');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserDownloads = async (sessionId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get(`/api/dashboard/user-downloads/${sessionId}`);
|
||||
setCurrentUser(response.data.user);
|
||||
setDownloads(response.data.downloads);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch downloads. Make sure all services are configured.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionChange = (e) => {
|
||||
const newSessionId = e.target.value;
|
||||
setSessionId(newSessionId);
|
||||
if (newSessionId) {
|
||||
fetchUserDownloads(newSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return 'N/A';
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Media Download Dashboard</h1>
|
||||
{currentUser && (
|
||||
<div className="user-info">
|
||||
<span className="user-label">Current User:</span>
|
||||
<span className="user-name">{currentUser}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="controls">
|
||||
<label htmlFor="session-select">Select Emby Session:</label>
|
||||
<select
|
||||
id="session-select"
|
||||
value={sessionId}
|
||||
onChange={handleSessionChange}
|
||||
className="session-select"
|
||||
>
|
||||
<option value="">-- Select Session --</option>
|
||||
{sessions.map(session => (
|
||||
<option key={session.Id} value={session.Id}>
|
||||
{session.UserName} - {session.Client} {session.NowPlayingItem ? `(Playing: ${session.NowPlayingItem.Name})` : '(Idle)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={fetchSessions} className="refresh-btn">Refresh Sessions</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="loading">Loading downloads...</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="downloads-container">
|
||||
<h2>Your Downloads</h2>
|
||||
{downloads.length === 0 ? (
|
||||
<div className="no-downloads">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with "user:yourusername" in Sonarr/Radarr.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="downloads-list">
|
||||
{downloads.map((download, index) => (
|
||||
<div key={index} className={`download-card ${download.type}`}>
|
||||
{download.coverArt && (
|
||||
<div className="download-cover">
|
||||
<img src={download.coverArt} alt={download.movieName || download.seriesName || download.title} />
|
||||
</div>
|
||||
)}
|
||||
<div className="download-info">
|
||||
<div className="download-header">
|
||||
<span className={`download-type ${download.type}`}>
|
||||
{download.type === 'series' ? '📺 Series' : '🎬 Movie'}
|
||||
</span>
|
||||
<span className={`download-status ${download.status}`}>
|
||||
{download.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="download-title">{download.title}</h3>
|
||||
{download.seriesName && (
|
||||
<p className="download-series">Series: {download.seriesName}</p>
|
||||
)}
|
||||
{download.movieName && (
|
||||
<p className="download-movie">Movie: {download.movieName}</p>
|
||||
)}
|
||||
<div className="download-details">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Size:</span>
|
||||
<span className="detail-value">{formatSize(download.size)}</span>
|
||||
</div>
|
||||
{download.progress && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Progress:</span>
|
||||
<span className="detail-value">{download.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{download.speed && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Speed:</span>
|
||||
<span className="detail-value">{download.speed}</span>
|
||||
</div>
|
||||
)}
|
||||
{download.eta && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">ETA:</span>
|
||||
<span className="detail-value">{download.eta}</span>
|
||||
</div>
|
||||
)}
|
||||
{download.completedAt && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Completed:</span>
|
||||
<span className="detail-value">{formatDate(download.completedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from './state.js';
|
||||
|
||||
export async function checkAuthentication() {
|
||||
try {
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.user.isAdmin;
|
||||
return { authenticated: true, user: data.user };
|
||||
} else {
|
||||
return { authenticated: false };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogin(username, password, rememberMe) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password, rememberMe })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) state.csrfToken = data.csrfToken;
|
||||
return { success: true, user: data.user };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { success: false, error: 'Login failed. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: state.csrfToken ? { 'X-CSRF-Token': state.csrfToken } : {}
|
||||
});
|
||||
state.currentUser = null;
|
||||
state.csrfToken = null;
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHistory(forceRefresh = false) {
|
||||
try {
|
||||
const params = new URLSearchParams({ days: state.historyDays });
|
||||
if (state.showAll) params.set('showAll', 'true');
|
||||
if (forceRefresh) params.set('_t', Date.now());
|
||||
const res = await fetch(`/api/history/recent?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { success: true, history: data.history || [] };
|
||||
} catch (err) {
|
||||
console.error('[History] Load error:', err);
|
||||
return { success: false, error: 'Failed to load history.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBlocklistSearch(download) {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': state.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
arrQueueId: download.arrQueueId,
|
||||
arrType: download.arrType,
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('[Blocklist] Error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAppVersion() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
const data = await res.json();
|
||||
return data.version || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookMetrics() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/webhook-metrics');
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookStatus() {
|
||||
try {
|
||||
// Fetch metrics in parallel
|
||||
const metricsPromise = fetchWebhookMetrics();
|
||||
|
||||
// Fetch Sonarr notifications
|
||||
let sonarrEnabled = false;
|
||||
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||
if (sonarrRes.ok) {
|
||||
const sonarrData = await sonarrRes.json();
|
||||
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||
sonarrEnabled = !!sonarrSofarr;
|
||||
if (sonarrSofarr) {
|
||||
sonarrTriggers = {
|
||||
onGrab: sonarrSofarr.onGrab,
|
||||
onDownload: sonarrSofarr.onDownload,
|
||||
onImport: sonarrSofarr.onImport,
|
||||
onUpgrade: sonarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Sonarr not configured
|
||||
}
|
||||
|
||||
// Fetch Radarr notifications
|
||||
let radarrEnabled = false;
|
||||
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const radarrRes = await fetch('/api/radarr/notifications');
|
||||
if (radarrRes.ok) {
|
||||
const radarrData = await radarrRes.json();
|
||||
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||
radarrEnabled = !!radarrSofarr;
|
||||
if (radarrSofarr) {
|
||||
radarrTriggers = {
|
||||
onGrab: radarrSofarr.onGrab,
|
||||
onDownload: radarrSofarr.onDownload,
|
||||
onImport: radarrSofarr.onImport,
|
||||
onUpgrade: radarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Radarr not configured
|
||||
}
|
||||
|
||||
state.webhookMetrics = await metricsPromise;
|
||||
|
||||
// Find instance stats
|
||||
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
|
||||
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||
|
||||
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
||||
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch webhook status:', err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableSonarrWebhook() {
|
||||
try {
|
||||
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to enable');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Sonarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableRadarrWebhook() {
|
||||
try {
|
||||
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to enable');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Radarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSonarrWebhook() {
|
||||
try {
|
||||
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||
const sonarrData = await sonarrRes.json();
|
||||
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
|
||||
|
||||
const res = await fetch('/api/sonarr/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': state.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify(sonarrSofarr)
|
||||
});
|
||||
if (!res.ok) throw new Error('Test failed');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to test Sonarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRadarrWebhook() {
|
||||
try {
|
||||
const radarrRes = await fetch('/api/radarr/notifications');
|
||||
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||
const radarrData = await radarrRes.json();
|
||||
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
|
||||
|
||||
const res = await fetch('/api/radarr/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': state.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify(radarrSofarr)
|
||||
});
|
||||
if (!res.ok) throw new Error('Test failed');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to test Radarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStatusPanel() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
|
||||
const data = await res.json();
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching status:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Bootstrap - wire all event handlers on DOMContentLoaded
|
||||
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
||||
import { initDownloadClientFilter } from './ui/filters.js';
|
||||
import { initHistoryControls } from './ui/history.js';
|
||||
import { toggleStatusPanel } from './ui/statusPanel.js';
|
||||
import { initWebhooks } from './ui/webhooks.js';
|
||||
import { initThemeSwitcher } from './ui/theme.js';
|
||||
import { initTabs, goHome } from './ui/tabs.js';
|
||||
import { handleShowAllToggle } from './sse.js';
|
||||
import { loadAppVersion } from './api.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Login form
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
}
|
||||
|
||||
// Logout button
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogoutClick);
|
||||
}
|
||||
|
||||
// Show all toggle
|
||||
const showAllToggle = document.getElementById('show-all-toggle');
|
||||
if (showAllToggle) {
|
||||
showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked));
|
||||
}
|
||||
|
||||
// Status panel toggle
|
||||
const statusToggle = document.getElementById('status-btn');
|
||||
if (statusToggle) {
|
||||
statusToggle.addEventListener('click', toggleStatusPanel);
|
||||
}
|
||||
|
||||
// Home button
|
||||
const homeBtn = document.getElementById('home-btn');
|
||||
if (homeBtn) {
|
||||
homeBtn.addEventListener('click', goHome);
|
||||
}
|
||||
|
||||
// Initialize UI components
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initDownloadClientFilter();
|
||||
initHistoryControls();
|
||||
initWebhooks();
|
||||
|
||||
// Load app version
|
||||
loadAppVersion().then(version => {
|
||||
const versionEl = document.getElementById('app-version');
|
||||
if (versionEl && version) {
|
||||
versionEl.textContent = 'v' + version;
|
||||
}
|
||||
});
|
||||
|
||||
// Check authentication and initialize
|
||||
checkAuthenticationAndInit();
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, SSE_RECONNECT_MS } from './state.js';
|
||||
import { renderDownloads } from './ui/downloads.js';
|
||||
import { hideError, hideLoading } from './ui/auth.js';
|
||||
import { loadHistory } from './ui/history.js';
|
||||
|
||||
export function startSSE() {
|
||||
stopSSE();
|
||||
const params = state.showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
state.sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.isAdmin;
|
||||
state.downloads = data.downloads;
|
||||
// Store download clients and update filter dropdown
|
||||
if (data.downloadClients) {
|
||||
state.downloadClients = data.downloadClients;
|
||||
// Trigger filter update
|
||||
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
|
||||
document.dispatchEvent(filterUpdateEvent);
|
||||
}
|
||||
document.getElementById('currentUser').textContent = state.currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for history-update events from server
|
||||
source.addEventListener('history-update', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[SSE] History update received:', data.type);
|
||||
// Trigger history reload
|
||||
const historyReloadEvent = new CustomEvent('historyReload');
|
||||
document.dispatchEvent(historyReloadEvent);
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse history-update message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
export function stopSSE() {
|
||||
if (state.sseReconnectTimer) { clearTimeout(state.sseReconnectTimer); state.sseReconnectTimer = null; }
|
||||
if (state.sseSource) {
|
||||
state.sseSource.close();
|
||||
state.sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
export function handleShowAllToggle(checked) {
|
||||
state.showAll = checked;
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
// Trigger history reload with updated showAll param
|
||||
const historyReloadEvent = new CustomEvent('historyReload');
|
||||
document.dispatchEvent(historyReloadEvent);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Global state (using objects for mutability across modules)
|
||||
export const state = {
|
||||
currentUser: null,
|
||||
downloads: [],
|
||||
downloadClients: [], // List of download clients from server (for ordering/filtering)
|
||||
selectedDownloadClients: [], // Array of selected client IDs for multi-select filter
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
|
||||
// History section state
|
||||
historyDays: 7, // Default value, will be loaded from localStorage
|
||||
historyRefreshHandle: null,
|
||||
ignoreAvailable: false, // Default value, will be loaded from localStorage
|
||||
lastHistoryItems: [], // raw items from last fetch, for re-filtering without a network round-trip
|
||||
|
||||
// SSE stream state
|
||||
sseSource: null,
|
||||
sseReconnectTimer: null,
|
||||
|
||||
// Status panel state
|
||||
statusRefreshHandle: null,
|
||||
|
||||
// Webhooks state
|
||||
webhookSectionExpanded: false,
|
||||
webhookLoading: false,
|
||||
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
webhookMetrics: null
|
||||
};
|
||||
|
||||
// Constants
|
||||
export const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
export const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
||||
export const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
export const STATUS_REFRESH_MS = 5000;
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, SPLASH_MIN_MS } from '../state.js';
|
||||
import { checkAuthentication, handleLogin as apiHandleLogin, handleLogout as apiHandleLogout } from '../api.js';
|
||||
import { startSSE, stopSSE } from '../sse.js';
|
||||
import { stopHistoryRefresh, clearHistory, startHistoryRefresh } from './history.js';
|
||||
import { closeStatusPanel } from './statusPanel.js';
|
||||
|
||||
export function fadeOutLogin() {
|
||||
return new Promise(resolve => {
|
||||
const login = document.getElementById('login-container');
|
||||
login.classList.add('fade-out');
|
||||
login.addEventListener('transitionend', () => {
|
||||
login.classList.add('hidden');
|
||||
login.classList.remove('fade-out');
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function showSplash() {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.classList.remove('hidden');
|
||||
splash.style.opacity = '1';
|
||||
splash.classList.remove('fade-out');
|
||||
}
|
||||
|
||||
export function dismissSplash(startTime) {
|
||||
return new Promise(resolve => {
|
||||
const elapsed = Date.now() - (startTime || 0);
|
||||
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.classList.add('fade-out');
|
||||
// Fallback: resolve after transition duration + buffer in case
|
||||
// transitionend never fires (e.g. display was toggled in same frame)
|
||||
const TRANSITION_MS = 400;
|
||||
const fallback = setTimeout(() => {
|
||||
splash.classList.add('hidden');
|
||||
resolve();
|
||||
}, TRANSITION_MS + 100);
|
||||
splash.addEventListener('transitionend', () => {
|
||||
clearTimeout(fallback);
|
||||
splash.classList.add('hidden');
|
||||
resolve();
|
||||
}, { once: true });
|
||||
}, remaining);
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkAuthenticationAndInit() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const result = await checkAuthentication();
|
||||
if (result.authenticated) {
|
||||
showDashboard();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const rememberMe = document.getElementById('remember-me').checked;
|
||||
|
||||
try {
|
||||
const result = await apiHandleLogin(username, password, rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
await fadeOutLogin();
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(result.error || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoginError('Login failed. Please try again.');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogoutClick() {
|
||||
try {
|
||||
stopSSE();
|
||||
stopHistoryRefresh();
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
await apiHandleLogout();
|
||||
state.currentUser = null;
|
||||
clearHistory();
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function showLogin() {
|
||||
document.getElementById('login-container').classList.remove('hidden');
|
||||
document.getElementById('dashboard-container').classList.add('hidden');
|
||||
hideLoginError();
|
||||
}
|
||||
|
||||
export function showDashboard() {
|
||||
document.getElementById('login-container').classList.add('hidden');
|
||||
document.getElementById('dashboard-container').classList.remove('hidden');
|
||||
document.getElementById('currentUser').textContent = state.currentUser.name || '-';
|
||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||
const sp = document.getElementById('status-panel');
|
||||
sp.classList.add('hidden');
|
||||
// Also hide webhooks-section to keep them in sync (both show/hide together)
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (webhooksSection) webhooksSection.classList.add('hidden');
|
||||
const adminControls = document.getElementById('admin-controls');
|
||||
if (state.isAdmin) {
|
||||
adminControls.classList.remove('hidden');
|
||||
} else {
|
||||
adminControls.classList.add('hidden');
|
||||
}
|
||||
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
|
||||
// Initialise days input from saved value
|
||||
const daysInput = document.getElementById('history-days');
|
||||
if (daysInput) daysInput.value = state.historyDays;
|
||||
startHistoryRefresh();
|
||||
}
|
||||
|
||||
export function showLoginError(message) {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideLoginError() {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideError() {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
import { handleBlocklistSearch } from '../api.js';
|
||||
|
||||
export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (showAll && tagBadges && tagBadges.length > 0) {
|
||||
const unmatched = tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
fragment.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
fragment.appendChild(badge);
|
||||
}
|
||||
} else if (matchedUserTag) {
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = matchedUserTag;
|
||||
fragment.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function createClientLogo(download) {
|
||||
const clientLogoWrapper = document.createElement('span');
|
||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||
|
||||
const clientLogo = document.createElement('img');
|
||||
clientLogo.className = 'download-client-logo';
|
||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||
clientLogo.title = download.instanceName || download.client;
|
||||
clientLogo.onerror = () => {
|
||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||
clientLogoWrapper.classList.add('fallback');
|
||||
};
|
||||
|
||||
clientLogoWrapper.appendChild(clientLogo);
|
||||
return clientLogoWrapper;
|
||||
}
|
||||
|
||||
export function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
|
||||
// Filter downloads by selected clients
|
||||
let filteredDownloads = state.downloads;
|
||||
if (state.selectedDownloadClients.length > 0) {
|
||||
// Map indices to client objects, then filter by both client type and instanceId
|
||||
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
|
||||
filteredDownloads = state.downloads.filter(d =>
|
||||
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort downloads by client order (matching the order in downloadClients)
|
||||
if (state.downloadClients.length > 0) {
|
||||
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
|
||||
filteredDownloads = [...filteredDownloads].sort((a, b) => {
|
||||
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
|
||||
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredDownloads.length === 0) {
|
||||
noDownloads.classList.remove('hidden');
|
||||
downloadsList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
noDownloads.classList.add('hidden');
|
||||
|
||||
// Get existing cards
|
||||
const existingCards = new Map();
|
||||
downloadsList.querySelectorAll('.download-card').forEach(card => {
|
||||
existingCards.set(card.dataset.id, card);
|
||||
});
|
||||
|
||||
// Track which downloads we've processed
|
||||
const processedIds = new Set();
|
||||
|
||||
filteredDownloads.forEach(download => {
|
||||
const id = download.title;
|
||||
processedIds.add(id);
|
||||
|
||||
const existingCard = existingCards.get(id);
|
||||
if (existingCard) {
|
||||
// Update existing card
|
||||
updateDownloadCard(existingCard, download);
|
||||
} else {
|
||||
// Create new card
|
||||
const card = createDownloadCard(download);
|
||||
downloadsList.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove cards for downloads that no longer exist
|
||||
existingCards.forEach((card, id) => {
|
||||
if (!processedIds.has(id)) {
|
||||
card.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDownloadCard(card, download) {
|
||||
// Remove old header-right container if it exists
|
||||
const oldRightSide = card.querySelector('.download-header-right');
|
||||
if (oldRightSide) {
|
||||
oldRightSide.remove();
|
||||
}
|
||||
|
||||
// Remove old user badges directly in header
|
||||
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
|
||||
oldBadges.forEach(badge => badge.remove());
|
||||
|
||||
// Remove old client logo from header (old structure)
|
||||
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
|
||||
if (oldLogoInHeader) {
|
||||
oldLogoInHeader.remove();
|
||||
}
|
||||
|
||||
// Remove old client logo from card (new structure) if it exists
|
||||
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
|
||||
if (oldLogoInCard) {
|
||||
oldLogoInCard.remove();
|
||||
}
|
||||
|
||||
// Add new right-side container with user badge only
|
||||
const header = card.querySelector('.download-header');
|
||||
if (header && !header.querySelector('.download-header-right')) {
|
||||
const rightSide = document.createElement('div');
|
||||
rightSide.className = 'download-header-right';
|
||||
|
||||
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
|
||||
rightSide.appendChild(badges);
|
||||
|
||||
header.appendChild(rightSide);
|
||||
}
|
||||
|
||||
// Add client logo to card (positioned at bottom right via CSS)
|
||||
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
|
||||
card.appendChild(createClientLogo(download));
|
||||
}
|
||||
|
||||
// Update status
|
||||
const statusEl = card.querySelector('.download-status');
|
||||
if (statusEl && statusEl.textContent !== download.status) {
|
||||
statusEl.textContent = download.status;
|
||||
statusEl.className = `download-status ${download.status}`;
|
||||
}
|
||||
|
||||
// Update progress bar and missing pieces
|
||||
const progressContainer = card.querySelector('.progress-container');
|
||||
if (progressContainer && download.progress !== undefined) {
|
||||
const progressBar = progressContainer.querySelector('.progress-bar');
|
||||
const progressText = progressContainer.querySelector('.progress-text');
|
||||
const missingText = progressContainer.querySelector('.missing-text');
|
||||
|
||||
if (progressBar) {
|
||||
const downloaded = progressBar.querySelector('.downloaded');
|
||||
if (downloaded) {
|
||||
downloaded.style.width = download.progress + '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = download.progress + '%';
|
||||
}
|
||||
|
||||
if (missingText) {
|
||||
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||
if (missingMb > 0 && totalMb > 0) {
|
||||
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||
} else {
|
||||
missingText.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update speed
|
||||
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
|
||||
if (speedEl && download.speed !== undefined) {
|
||||
speedEl.textContent = formatSpeed(download.speed);
|
||||
}
|
||||
|
||||
// Update ETA
|
||||
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
|
||||
if (etaEl && download.eta !== undefined) {
|
||||
etaEl.textContent = download.eta;
|
||||
}
|
||||
|
||||
// Update qBittorrent-specific fields
|
||||
if (download.qbittorrent) {
|
||||
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
|
||||
if (seedsEl && download.seeds !== undefined) {
|
||||
seedsEl.textContent = download.seeds;
|
||||
}
|
||||
|
||||
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
|
||||
if (peersEl && download.peers !== undefined) {
|
||||
peersEl.textContent = download.peers;
|
||||
}
|
||||
|
||||
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||
if (availabilityItem && download.availability !== undefined) {
|
||||
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBlocklistSearchClick(btn, download) {
|
||||
console.log('[Blocklist] Clicked, download:', download);
|
||||
console.log('[Blocklist] Required fields:', {
|
||||
arrQueueId: download.arrQueueId,
|
||||
arrType: download.arrType,
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentType: download.arrContentType,
|
||||
isAdmin: state.isAdmin,
|
||||
canBlocklist: download.canBlocklist
|
||||
});
|
||||
|
||||
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Working…';
|
||||
|
||||
try {
|
||||
await handleBlocklistSearch(download);
|
||||
btn.textContent = '✓ Done — searching…';
|
||||
btn.className = 'blocklist-search-btn success';
|
||||
} catch (err) {
|
||||
console.error('[Blocklist] Error:', err);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '⛔ Blocklist & Search';
|
||||
btn.className = 'blocklist-search-btn error';
|
||||
btn.title = `Failed: ${err.message}`;
|
||||
setTimeout(() => {
|
||||
btn.className = 'blocklist-search-btn';
|
||||
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger an automatic search';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDownloadCard(download) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `download-card ${download.type}`;
|
||||
card.dataset.id = download.title;
|
||||
|
||||
// Cover art
|
||||
if (download.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'download-cover';
|
||||
const coverImg = document.createElement('img');
|
||||
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||
// is satisfied (external poster URLs would be blocked otherwise).
|
||||
coverImg.src = download.coverArt
|
||||
? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt)
|
||||
: '';
|
||||
coverImg.alt = download.movieName || download.seriesName || download.title;
|
||||
coverImg.loading = 'lazy';
|
||||
coverDiv.appendChild(coverImg);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
// Info wrapper
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'download-info';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'download-header';
|
||||
|
||||
const type = document.createElement('span');
|
||||
type.className = `download-type ${download.type}`;
|
||||
if (download.type === 'series') {
|
||||
type.textContent = '📺 Series';
|
||||
} else if (download.type === 'movie') {
|
||||
type.textContent = '🎬 Movie';
|
||||
} else if (download.type === 'torrent') {
|
||||
const instName = download.instanceName ? ` (${download.instanceName})` : '';
|
||||
type.textContent = `📥 Torrent${instName}`;
|
||||
} else {
|
||||
type.textContent = download.type;
|
||||
}
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = `download-status ${download.status}`;
|
||||
status.textContent = download.status;
|
||||
|
||||
header.appendChild(type);
|
||||
header.appendChild(status);
|
||||
|
||||
if (download.importIssues && download.importIssues.length > 0) {
|
||||
const issueBadge = document.createElement('span');
|
||||
issueBadge.className = 'import-issue-badge';
|
||||
issueBadge.textContent = 'Import Pending';
|
||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||
header.appendChild(issueBadge);
|
||||
}
|
||||
|
||||
if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||
const blBtn = document.createElement('button');
|
||||
blBtn.className = 'blocklist-search-btn';
|
||||
blBtn.textContent = '⛔ Blocklist & Search';
|
||||
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
|
||||
// Right side container for user badge only
|
||||
const rightSide = document.createElement('div');
|
||||
rightSide.className = 'download-header-right';
|
||||
|
||||
const badges = renderTagBadges(download.tagBadges, state.showAll, download.matchedUserTag);
|
||||
rightSide.appendChild(badges);
|
||||
|
||||
header.appendChild(rightSide);
|
||||
|
||||
// Add client logo to card (positioned at bottom right via CSS)
|
||||
if (download.client) {
|
||||
card.appendChild(createClientLogo(download));
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'download-title';
|
||||
title.textContent = download.title;
|
||||
|
||||
infoDiv.appendChild(header);
|
||||
infoDiv.appendChild(title);
|
||||
|
||||
if (download.seriesName) {
|
||||
const series = document.createElement('p');
|
||||
series.className = 'download-series';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
||||
} else {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
}
|
||||
infoDiv.appendChild(series);
|
||||
const epEl = formatEpisodeInfo(download.episodes);
|
||||
if (epEl) infoDiv.appendChild(epEl);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
||||
} else {
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
}
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'download-details';
|
||||
|
||||
const size = createDetailItem('Size', formatSize(download.size));
|
||||
details.appendChild(size);
|
||||
|
||||
if (download.progress !== undefined) {
|
||||
const progressItem = document.createElement('div');
|
||||
progressItem.className = 'detail-item progress-item';
|
||||
progressItem.dataset.label = 'Progress';
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = 'Progress';
|
||||
|
||||
const valueDiv = document.createElement('div');
|
||||
valueDiv.className = 'progress-container';
|
||||
|
||||
// Progress bar with segments
|
||||
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
|
||||
const missingMb = parseFloat(download.mbmissing) || 0;
|
||||
const downloadedMb = totalMb - missingMb;
|
||||
const progressPercent = parseFloat(download.progress) || 0;
|
||||
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
|
||||
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.className = 'progress-bar';
|
||||
|
||||
// Downloaded portion (green)
|
||||
if (progressPercent > 0) {
|
||||
const downloaded = document.createElement('div');
|
||||
downloaded.className = 'progress-segment downloaded';
|
||||
downloaded.style.width = progressPercent + '%';
|
||||
progressBar.appendChild(downloaded);
|
||||
}
|
||||
|
||||
valueDiv.appendChild(progressBar);
|
||||
|
||||
// Text showing percentage
|
||||
const progressText = document.createElement('span');
|
||||
progressText.className = 'progress-text';
|
||||
progressText.textContent = download.progress + '%';
|
||||
valueDiv.appendChild(progressText);
|
||||
|
||||
// Missing pieces text (only for torrent clients like qBittorrent)
|
||||
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
|
||||
const missingText = document.createElement('span');
|
||||
missingText.className = 'missing-text';
|
||||
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
|
||||
valueDiv.appendChild(missingText);
|
||||
}
|
||||
|
||||
progressItem.appendChild(labelSpan);
|
||||
progressItem.appendChild(valueDiv);
|
||||
details.appendChild(progressItem);
|
||||
}
|
||||
|
||||
if (download.speed && download.speed > 0) {
|
||||
const speed = createDetailItem('Speed', formatSpeed(download.speed));
|
||||
details.appendChild(speed);
|
||||
}
|
||||
|
||||
if (download.eta) {
|
||||
const eta = createDetailItem('ETA', download.eta);
|
||||
details.appendChild(eta);
|
||||
}
|
||||
|
||||
// qBittorrent-specific fields
|
||||
if (download.qbittorrent) {
|
||||
if (download.seeds !== undefined) {
|
||||
const seeds = createDetailItem('Seeds', download.seeds);
|
||||
details.appendChild(seeds);
|
||||
}
|
||||
|
||||
if (download.peers !== undefined) {
|
||||
const peers = createDetailItem('Peers', download.peers);
|
||||
details.appendChild(peers);
|
||||
}
|
||||
|
||||
if (download.availability !== undefined) {
|
||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
|
||||
if (download.completedAt) {
|
||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||
details.appendChild(completed);
|
||||
}
|
||||
|
||||
if (state.isAdmin && (download.downloadPath || download.targetPath)) {
|
||||
const pathsDiv = document.createElement('div');
|
||||
pathsDiv.className = 'download-paths';
|
||||
if (download.downloadPath) {
|
||||
const dlPath = document.createElement('div');
|
||||
dlPath.className = 'path-item';
|
||||
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||
pathsDiv.appendChild(dlPath);
|
||||
}
|
||||
if (download.targetPath) {
|
||||
const tgtPath = document.createElement('div');
|
||||
tgtPath.className = 'path-item';
|
||||
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||
pathsDiv.appendChild(tgtPath);
|
||||
}
|
||||
details.appendChild(pathsDiv);
|
||||
}
|
||||
|
||||
infoDiv.appendChild(details);
|
||||
card.appendChild(infoDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export function createDetailItem(label, value) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'detail-item';
|
||||
item.dataset.label = label;
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = label;
|
||||
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'detail-value';
|
||||
valueSpan.textContent = value;
|
||||
|
||||
item.appendChild(labelSpan);
|
||||
item.appendChild(valueSpan);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { saveDownloadClients } from '../utils/storage.js';
|
||||
import { renderDownloads } from './downloads.js';
|
||||
|
||||
export function initDownloadClientFilter() {
|
||||
const filterBtn = document.getElementById('download-client-filter-btn');
|
||||
const filterDropdown = document.getElementById('download-client-filter-dropdown');
|
||||
const filterClose = document.getElementById('download-client-filter-close');
|
||||
|
||||
if (!filterBtn || !filterDropdown) return;
|
||||
|
||||
filterBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
filterDropdown.classList.toggle('open');
|
||||
});
|
||||
|
||||
filterClose.addEventListener('click', () => {
|
||||
filterDropdown.classList.remove('open');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!filterDropdown.contains(e.target) && e.target !== filterBtn) {
|
||||
filterDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for download clients updates from SSE
|
||||
document.addEventListener('downloadClientsUpdated', updateDownloadClientFilter);
|
||||
|
||||
// Initial filter update
|
||||
updateDownloadClientFilter();
|
||||
}
|
||||
|
||||
export function updateDownloadClientFilter() {
|
||||
const filterList = document.getElementById('download-client-filter-list');
|
||||
if (!filterList) return;
|
||||
|
||||
filterList.innerHTML = '';
|
||||
|
||||
state.downloadClients.forEach((client, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'filter-item';
|
||||
item.dataset.index = index;
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = `client-${index}`;
|
||||
checkbox.checked = state.selectedDownloadClients.includes(index);
|
||||
checkbox.addEventListener('change', () => toggleClientSelection(index));
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `client-${index}`;
|
||||
label.textContent = client.name || `${client.type} (${client.id})`;
|
||||
|
||||
item.appendChild(checkbox);
|
||||
item.appendChild(label);
|
||||
filterList.appendChild(item);
|
||||
});
|
||||
|
||||
updateSelectedCountDisplay();
|
||||
}
|
||||
|
||||
export function toggleClientSelection(index) {
|
||||
const idx = state.selectedDownloadClients.indexOf(index);
|
||||
if (idx > -1) {
|
||||
state.selectedDownloadClients.splice(idx, 1);
|
||||
} else {
|
||||
state.selectedDownloadClients.push(index);
|
||||
}
|
||||
saveDownloadClients(state.selectedDownloadClients);
|
||||
updateSelectedCountDisplay();
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
export function updateSelectedCountDisplay() {
|
||||
const countDisplay = document.getElementById('download-client-filter-count');
|
||||
if (!countDisplay) return;
|
||||
|
||||
if (state.selectedDownloadClients.length === 0) {
|
||||
countDisplay.textContent = 'All';
|
||||
} else {
|
||||
countDisplay.textContent = state.selectedDownloadClients.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, HISTORY_REFRESH_MS } from '../state.js';
|
||||
import { loadHistory as apiLoadHistory } from '../api.js';
|
||||
import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
|
||||
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
import { renderTagBadges } from './downloads.js';
|
||||
|
||||
export function initHistoryControls() {
|
||||
const daysInput = document.getElementById('history-days');
|
||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||
const ignoreToggle = document.getElementById('ignore-available-toggle');
|
||||
if (daysInput) {
|
||||
daysInput.addEventListener('change', () => {
|
||||
const v = parseInt(daysInput.value, 10);
|
||||
if (v > 0 && v <= 90) {
|
||||
historyDays = v;
|
||||
saveHistoryDays(v);
|
||||
loadHistory(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||
}
|
||||
if (ignoreToggle) {
|
||||
ignoreToggle.checked = state.ignoreAvailable;
|
||||
ignoreToggle.addEventListener('change', () => {
|
||||
state.ignoreAvailable = ignoreToggle.checked;
|
||||
saveIgnoreAvailable(state.ignoreAvailable);
|
||||
renderHistory(state.lastHistoryItems);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for history reload events from other modules
|
||||
document.addEventListener('historyReload', () => {
|
||||
loadHistory(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function startHistoryRefresh() {
|
||||
stopHistoryRefresh();
|
||||
state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function stopHistoryRefresh() {
|
||||
if (state.historyRefreshHandle) {
|
||||
clearInterval(state.historyRefreshHandle);
|
||||
state.historyRefreshHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearHistory() {
|
||||
state.lastHistoryItems = [];
|
||||
document.getElementById('history-list').innerHTML = '';
|
||||
document.getElementById('no-history').classList.add('hidden');
|
||||
document.getElementById('history-error').classList.add('hidden');
|
||||
}
|
||||
|
||||
export async function loadHistory(forceRefresh = false) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const loadingEl = document.getElementById('history-loading');
|
||||
const errorEl = document.getElementById('history-error');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
|
||||
loadingEl.classList.remove('hidden');
|
||||
errorEl.classList.add('hidden');
|
||||
noHistoryEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const result = await apiLoadHistory(forceRefresh);
|
||||
loadingEl.classList.add('hidden');
|
||||
if (result.success) {
|
||||
state.lastHistoryItems = result.history;
|
||||
renderHistory(state.lastHistoryItems);
|
||||
} else {
|
||||
errorEl.textContent = result.error || 'Failed to load history.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
loadingEl.classList.add('hidden');
|
||||
errorEl.textContent = 'Failed to load history.';
|
||||
errorEl.classList.remove('hidden');
|
||||
console.error('[History] Load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHistory(items) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
listEl.innerHTML = '';
|
||||
const visible = state.ignoreAvailable
|
||||
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
|
||||
: items;
|
||||
if (!visible.length) {
|
||||
noHistoryEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
noHistoryEl.classList.add('hidden');
|
||||
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||
}
|
||||
|
||||
export function createHistoryCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `history-card ${item.type} ${item.outcome}`;
|
||||
|
||||
if (item.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'history-cover';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
||||
img.alt = item.movieName || item.seriesName || item.title;
|
||||
img.loading = 'lazy';
|
||||
coverDiv.appendChild(img);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'history-info';
|
||||
|
||||
// Header row: type badge + outcome badge
|
||||
const header = document.createElement('div');
|
||||
header.className = 'history-card-header';
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = `history-type-badge ${item.type}`;
|
||||
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
||||
header.appendChild(typeBadge);
|
||||
|
||||
const outcomeBadge = document.createElement('span');
|
||||
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
||||
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||
header.appendChild(outcomeBadge);
|
||||
|
||||
if (item.availableForUpgrade) {
|
||||
const upgradeBadge = document.createElement('span');
|
||||
upgradeBadge.className = 'history-upgrade-badge';
|
||||
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
|
||||
upgradeBadge.textContent = '⬆ Available';
|
||||
header.appendChild(upgradeBadge);
|
||||
}
|
||||
|
||||
if (item.instanceName) {
|
||||
const instBadge = document.createElement('span');
|
||||
instBadge.className = 'history-instance-badge';
|
||||
instBadge.textContent = item.instanceName;
|
||||
header.appendChild(instBadge);
|
||||
}
|
||||
|
||||
const badges = renderTagBadges(item.tagBadges, state.showAll, item.matchedUserTag);
|
||||
header.appendChild(badges);
|
||||
|
||||
info.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'history-title';
|
||||
title.textContent = item.title;
|
||||
info.appendChild(title);
|
||||
|
||||
// Series/movie name with optional arr link
|
||||
if (item.seriesName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
}
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Movie: ' + item.movieName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
}
|
||||
|
||||
// Detail pills
|
||||
const details = document.createElement('div');
|
||||
details.className = 'history-details';
|
||||
|
||||
if (item.completedAt) {
|
||||
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
||||
}
|
||||
if (item.quality) {
|
||||
details.appendChild(createDetailItem('Quality', item.quality));
|
||||
}
|
||||
|
||||
// Failed imports: show failure message
|
||||
if (item.outcome === 'failed' && item.failureMessage) {
|
||||
const failItem = document.createElement('div');
|
||||
failItem.className = 'history-failure-message';
|
||||
failItem.textContent = item.failureMessage;
|
||||
details.appendChild(failItem);
|
||||
}
|
||||
|
||||
info.appendChild(details);
|
||||
card.appendChild(info);
|
||||
return card;
|
||||
}
|
||||
|
||||
function createDetailItem(label, value) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'detail-item';
|
||||
item.dataset.label = label;
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'detail-label';
|
||||
labelSpan.textContent = label;
|
||||
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'detail-value';
|
||||
valueSpan.textContent = value;
|
||||
|
||||
item.appendChild(labelSpan);
|
||||
item.appendChild(valueSpan);
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, STATUS_REFRESH_MS } from '../state.js';
|
||||
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
|
||||
import { fetchWebhookStatus } from './webhooks.js';
|
||||
|
||||
export async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (!panel.classList.contains('hidden')) {
|
||||
// Close both panels (webhooks is a sibling, hide it too)
|
||||
panel.classList.add('hidden');
|
||||
if (webhooksSection) webhooksSection.classList.add('hidden');
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
return;
|
||||
}
|
||||
// Open status panel and webhooks section (siblings)
|
||||
panel.classList.remove('hidden');
|
||||
// Show webhooks section for admin users (collapsed by default)
|
||||
if (webhooksSection && state.isAdmin) {
|
||||
webhooksSection.classList.remove('hidden');
|
||||
state.webhookSectionExpanded = false;
|
||||
document.getElementById('webhooks-content').classList.add('hidden');
|
||||
document.getElementById('webhooks-toggle').classList.remove('expanded');
|
||||
await fetchWebhookStatus();
|
||||
} else if (webhooksSection) {
|
||||
webhooksSection.classList.add('hidden');
|
||||
}
|
||||
refreshStatusPanel();
|
||||
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
|
||||
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function closeStatusPanel() {
|
||||
document.getElementById('status-panel').classList.add('hidden');
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (webhooksSection) webhooksSection.classList.add('hidden');
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
}
|
||||
|
||||
export async function refreshStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
|
||||
if (!panel || panel.classList.contains('hidden')) return;
|
||||
console.log('[Status] Refreshing status panel...');
|
||||
try {
|
||||
const result = await apiRefreshStatusPanel();
|
||||
if (result.success) {
|
||||
console.log('[Status] Got status data, rendering...');
|
||||
renderStatusPanel(result.data, panel);
|
||||
} else {
|
||||
console.error('[Status] API returned error:', result.error);
|
||||
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
|
||||
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + result.error + '</p>';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching status:', err);
|
||||
// Don't overwrite panel on transient error during auto-refresh
|
||||
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
|
||||
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderStatusPanel(data, panel) {
|
||||
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
|
||||
const s = data.server;
|
||||
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
||||
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
||||
const secs = s.uptimeSeconds % 60;
|
||||
const uptime = `${hrs}h ${mins}m ${secs}s`;
|
||||
|
||||
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
|
||||
|
||||
let html = `
|
||||
<div class="status-header">
|
||||
<h3>Server Status</h3>
|
||||
<button class="status-close" id="status-close-btn">×</button>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Server</div>
|
||||
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
||||
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
||||
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
||||
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Data Refresh</div>`;
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Webhook metrics card (admin only)
|
||||
if (state.isAdmin && data.webhooks) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<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>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 status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Poll timings card
|
||||
const lp = data.polling.lastPoll;
|
||||
if (lp) {
|
||||
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Cache table
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
||||
<table class="status-table">
|
||||
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const e of data.cache.entries) {
|
||||
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
|
||||
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
||||
const items = e.itemCount !== null ? e.itemCount : '—';
|
||||
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
||||
}
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
// Render into status-content div, not the whole panel (preserves webhooks section)
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
const panelCheck = document.getElementById('status-panel');
|
||||
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
|
||||
if (panelCheck) {
|
||||
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
|
||||
}
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = html;
|
||||
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
|
||||
} else {
|
||||
console.error('[Status] contentDiv not found!');
|
||||
}
|
||||
// Wire close button — addEventListener avoids CSP inline handler restrictions
|
||||
const closeBtn = document.getElementById('status-close-btn');
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
|
||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||
el.style.width = el.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
||||
import { loadHistory } from './history.js';
|
||||
|
||||
export function initTabs() {
|
||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||
const historyTab = document.querySelector('[data-tab="history"]');
|
||||
|
||||
if (!downloadsTab || !historyTab) return;
|
||||
|
||||
// Load saved tab
|
||||
const savedTab = getActiveTab();
|
||||
if (savedTab === 'history') {
|
||||
activateTab('history');
|
||||
} else {
|
||||
activateTab('downloads');
|
||||
}
|
||||
|
||||
downloadsTab.addEventListener('click', () => activateTab('downloads'));
|
||||
historyTab.addEventListener('click', () => activateTab('history'));
|
||||
}
|
||||
|
||||
export function activateTab(tab) {
|
||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||
const historyTab = document.querySelector('[data-tab="history"]');
|
||||
const downloadsSection = document.getElementById('tab-downloads');
|
||||
const historySection = document.getElementById('tab-history');
|
||||
|
||||
if (tab === 'downloads') {
|
||||
downloadsTab.classList.add('active');
|
||||
historyTab.classList.remove('active');
|
||||
downloadsSection.classList.remove('hidden');
|
||||
historySection.classList.add('hidden');
|
||||
saveActiveTab('downloads');
|
||||
} else if (tab === 'history') {
|
||||
historyTab.classList.add('active');
|
||||
downloadsTab.classList.remove('active');
|
||||
historySection.classList.remove('hidden');
|
||||
downloadsSection.classList.add('hidden');
|
||||
saveActiveTab('history');
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
export function goHome() {
|
||||
activateTab('downloads');
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { getTheme, saveTheme } from '../utils/storage.js';
|
||||
|
||||
// Apply saved theme immediately on load
|
||||
(function applyTheme() {
|
||||
const theme = getTheme();
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
})();
|
||||
|
||||
export function initThemeSwitcher() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (!themeToggle) return;
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = getTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
saveTheme(theme);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js';
|
||||
import { formatTimeAgo } from '../utils/format.js';
|
||||
|
||||
export function initWebhooks() {
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (!webhooksSection) return;
|
||||
|
||||
// Note: visibility is controlled by showDashboard() based on isAdmin
|
||||
|
||||
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
|
||||
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
|
||||
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
|
||||
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
|
||||
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
|
||||
}
|
||||
|
||||
export function toggleWebhookSection() {
|
||||
state.webhookSectionExpanded = !state.webhookSectionExpanded;
|
||||
const content = document.getElementById('webhooks-content');
|
||||
const toggle = document.getElementById('webhooks-toggle');
|
||||
|
||||
if (state.webhookSectionExpanded) {
|
||||
content.classList.remove('hidden');
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
}
|
||||
toggle.classList.toggle('expanded', state.webhookSectionExpanded);
|
||||
|
||||
if (state.webhookSectionExpanded) {
|
||||
fetchWebhookStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookStatus() {
|
||||
const loadingEl = document.getElementById('webhook-loading');
|
||||
loadingEl.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const result = await apiFetchWebhookStatus();
|
||||
if (result.success) {
|
||||
renderWebhookStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch webhook status:', err);
|
||||
} finally {
|
||||
loadingEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
export function renderWebhookStatus() {
|
||||
// Sonarr
|
||||
const sonarrStatus = document.getElementById('sonarr-status');
|
||||
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
|
||||
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
|
||||
const sonarrTriggers = document.getElementById('sonarr-triggers');
|
||||
const sonarrStats = document.getElementById('sonarr-stats');
|
||||
|
||||
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||
if (sonarrWebhook.enabled) {
|
||||
sonarrEnableBtn.classList.add('hidden');
|
||||
sonarrTestBtn.classList.remove('hidden');
|
||||
sonarrTriggers.classList.remove('hidden');
|
||||
} else {
|
||||
sonarrEnableBtn.classList.remove('hidden');
|
||||
sonarrTestBtn.classList.add('hidden');
|
||||
sonarrTriggers.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (sonarrWebhook.enabled) {
|
||||
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||
}
|
||||
|
||||
if (sonarrWebhook.stats) {
|
||||
sonarrStats.classList.remove('hidden');
|
||||
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
|
||||
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
|
||||
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
|
||||
} else {
|
||||
sonarrStats.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Radarr
|
||||
const radarrStatus = document.getElementById('radarr-status');
|
||||
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
|
||||
const radarrTestBtn = document.getElementById('test-radarr-webhook');
|
||||
const radarrTriggers = document.getElementById('radarr-triggers');
|
||||
const radarrStats = document.getElementById('radarr-stats');
|
||||
|
||||
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||
if (radarrWebhook.enabled) {
|
||||
radarrEnableBtn.classList.add('hidden');
|
||||
radarrTestBtn.classList.remove('hidden');
|
||||
radarrTriggers.classList.remove('hidden');
|
||||
} else {
|
||||
radarrEnableBtn.classList.remove('hidden');
|
||||
radarrTestBtn.classList.add('hidden');
|
||||
radarrTriggers.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (radarrWebhook.enabled) {
|
||||
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||
}
|
||||
|
||||
if (radarrWebhook.stats) {
|
||||
radarrStats.classList.remove('hidden');
|
||||
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
|
||||
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
|
||||
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
|
||||
} else {
|
||||
radarrStats.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableSonarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiEnableSonarrWebhook();
|
||||
if (!result.success) {
|
||||
console.error('Failed to enable Sonarr webhook:', result.error);
|
||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Sonarr webhook:', err);
|
||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableRadarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiEnableRadarrWebhook();
|
||||
if (!result.success) {
|
||||
console.error('Failed to enable Radarr webhook:', result.error);
|
||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Radarr webhook:', err);
|
||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSonarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiTestSonarrWebhook();
|
||||
if (result.success) {
|
||||
alert('Sonarr webhook test sent successfully!');
|
||||
} else {
|
||||
console.error('Failed to test Sonarr webhook:', result.error);
|
||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Sonarr webhook:', err);
|
||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRadarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiTestRadarrWebhook();
|
||||
if (result.success) {
|
||||
alert('Radarr webhook test sent successfully!');
|
||||
} else {
|
||||
console.error('Failed to test Radarr webhook:', result.error);
|
||||
alert('Failed to test Radarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Radarr webhook:', err);
|
||||
alert('Failed to test Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function setWebhookLoading(loading) {
|
||||
state.webhookLoading = loading;
|
||||
document.getElementById('enable-sonarr-webhook').disabled = loading;
|
||||
document.getElementById('enable-radarr-webhook').disabled = loading;
|
||||
document.getElementById('test-sonarr-webhook').disabled = loading;
|
||||
document.getElementById('test-radarr-webhook').disabled = loading;
|
||||
const loadingEl = document.getElementById('webhook-loading');
|
||||
if (loading) {
|
||||
loadingEl.classList.remove('hidden');
|
||||
} else {
|
||||
loadingEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
export function formatSize(size) {
|
||||
if (!size) return 'N/A';
|
||||
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
||||
if (typeof size === 'string') {
|
||||
return size;
|
||||
}
|
||||
// If it's a number (bytes), format it
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024));
|
||||
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSecond) {
|
||||
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
let value = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return seconds + 's ago';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + 'm ago';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
return Math.floor(hours / 24) + 'd ago';
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Build an episode-info element for series downloads/history.
|
||||
// Single episode: "S01E05 — Episode Title"
|
||||
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
||||
// Returns null if no episode data.
|
||||
export function formatEpisodeInfo(episodes) {
|
||||
if (!episodes || episodes.length === 0) return null;
|
||||
const el = document.createElement('p');
|
||||
el.className = 'episode-info';
|
||||
if (episodes.length === 1) {
|
||||
const ep = episodes[0];
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
} else {
|
||||
el.textContent = 'Multiple episodes';
|
||||
el.classList.add('multi-episode');
|
||||
const lines = episodes.map(ep => {
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
});
|
||||
el.setAttribute('data-tooltip', lines.join('\n'));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
|
||||
// Migration from old single-select to new multi-select format
|
||||
(function migrateDownloadClientFilter() {
|
||||
const oldSelection = localStorage.getItem('sofarr-download-client');
|
||||
if (oldSelection && oldSelection !== 'all') {
|
||||
try {
|
||||
state.selectedDownloadClients = [oldSelection];
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(state.selectedDownloadClients));
|
||||
localStorage.removeItem('sofarr-download-client');
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to migrate download client filter:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const newSelection = localStorage.getItem('sofarr-download-clients');
|
||||
state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to load download client filter:', e);
|
||||
state.selectedDownloadClients = [];
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Load history days from localStorage
|
||||
(function loadHistorySettings() {
|
||||
try {
|
||||
const savedDays = localStorage.getItem('sofarr-history-days');
|
||||
if (savedDays) {
|
||||
state.historyDays = parseInt(savedDays, 10) || 7;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load history days:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Load ignore available setting from localStorage
|
||||
(function loadIgnoreAvailable() {
|
||||
try {
|
||||
const saved = localStorage.getItem('sofarr-ignore-available');
|
||||
state.ignoreAvailable = saved === 'true';
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load ignore available:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Export helper functions for localStorage operations
|
||||
export function saveHistoryDays(days) {
|
||||
localStorage.setItem('sofarr-history-days', days);
|
||||
}
|
||||
|
||||
export function saveIgnoreAvailable(value) {
|
||||
localStorage.setItem('sofarr-ignore-available', value);
|
||||
}
|
||||
|
||||
export function saveDownloadClients(clients) {
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(clients));
|
||||
}
|
||||
|
||||
export function getTheme() {
|
||||
return localStorage.getItem('sofarr-theme') || 'light';
|
||||
}
|
||||
|
||||
export function saveTheme(theme) {
|
||||
localStorage.setItem('sofarr-theme', theme);
|
||||
}
|
||||
|
||||
export function getActiveTab() {
|
||||
return localStorage.getItem('sofarr-active-tab') || 'downloads';
|
||||
}
|
||||
|
||||
export function saveActiveTab(tab) {
|
||||
localStorage.setItem('sofarr-active-tab', tab);
|
||||
}
|
||||
+14
-2
@@ -1,9 +1,21 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../public',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
+5
-1
@@ -44,13 +44,17 @@ services:
|
||||
volumes:
|
||||
# Persistent volume for token store and log file
|
||||
- sofarr-data:/app/data
|
||||
# Mount code for development (comment out in production)
|
||||
- ./server:/app/server
|
||||
- ./public:/app/public
|
||||
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
|
||||
# - /path/to/your/server.crt:/app/certs/server.crt:ro
|
||||
# - /path/to/your/server.key:/app/certs/server.key:ro
|
||||
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||
user: "1000:1000"
|
||||
# Read-only root filesystem; only the data volume is writable
|
||||
read_only: true
|
||||
# Comment out for development when mounting code volumes
|
||||
# read_only: true
|
||||
tmpfs:
|
||||
- /tmp # Node.js needs a writable /tmp
|
||||
security_opt:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.0",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+28
-1135
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m256.1 2.6 142 217.2c91 139.2-4.7 289.6-142.1 289.6S22.8 358.9 113.9 219.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#4c90e8;stroke:#094491;stroke-width:3.1904"/><path d="M306.7 255.2c-77.6-31.7-118.4 49.2-105.4 87C229.5 424.4 335.8 445 414.2 308c0 0 .8 16.5 1.7 24.4 10.5 99.9-73.5 163.4-158.6 162.2s-111.1-34.3-136.2-72.3C80.4 360.6 90.5 260 145.2 212.9c62.5-51.6 131.2-24 161.5 42.3" style="fill-rule:evenodd;clip-rule:evenodd;fill:#094491"/><path d="M257.9 225.3c-87 2.1-103.5 102.3-79.4 145.3 33.5 59.7 84.3 71.2 153.8 49.1-39.7 43.2-121.2 54.6-176.5-7.1-38.1-42.4-41.4-101.6-15-151.1 26.5-49.4 79.2-63.2 117.1-36.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#83b8f9"/></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="348.2829" x2="782.05951" y1="0" y2="786.48322"><stop offset="0" stop-color="#72b4f5"/><stop offset="1" stop-color="#356ebf"/></linearGradient><g fill="none" fill-rule="evenodd" transform="matrix(.97656268 0 0 .9765624 11.999908 12.000051)"><circle cx="512" cy="512" fill="url(#a)" r="496" stroke="#daefff" stroke-width="32"/><path d="m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z" fill="#fff"/><path d="m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z" fill="#c8e8ff"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linejoin="round" stroke-width="74" d="M200.4 39.3h598.1v437.8h161l-460.1 483L39.4 477h161z"/><path fill="#ffb300" fill-rule="evenodd" d="M200.4 39.3h598.1v437.8h161l-460.1 483-460-483h161z"/><path fill="#ffca28" fill-rule="evenodd" d="M499.4 960.2 201.1 39.4h596.7z"/><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linecap="round" stroke-linejoin="round" stroke-width="74" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-97.8h46v46h-46zm192.1 97.8v-344h100.1v97.4h146.1v246.6zm100.1-195.2h46v143.4h-46z"/><path fill="#0f0f0f" fill-rule="evenodd" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-51.8h46v-46h-46zm192.1 51.9v-344h100.1V597h146.1v246.6zm100.1-51.9h46V648.4h-46z"/></svg>
|
||||
|
After Width: | Height: | Size: 966 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
+94
-12
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="app">
|
||||
<!-- Login Form -->
|
||||
<div id="login-container" class="login-container" style="display: none;">
|
||||
<div id="login-container" class="login-container hidden">
|
||||
<div class="login-box">
|
||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
||||
<p class="login-subtitle">Login with your Emby credentials</p>
|
||||
@@ -39,12 +39,12 @@
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
<div id="login-error" class="error-message hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||
<div id="dashboard-container" class="dashboard-container hidden">
|
||||
<header class="app-header">
|
||||
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||
<div class="header-controls">
|
||||
@@ -53,7 +53,7 @@
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||
</div>
|
||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||
<div id="admin-controls" class="admin-controls hidden">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
<span>Show all users</span>
|
||||
@@ -68,11 +68,73 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
||||
<div id="status-panel" class="status-panel hidden">
|
||||
<!-- Status content gets rendered here -->
|
||||
<div id="status-content"><p class="status-loading">Loading status...</p></div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
|
||||
<div class="webhooks-section hidden" id="webhooks-section">
|
||||
<div class="webhooks-header" id="webhooks-header">
|
||||
<h2>⚡ Webhooks Configuration</h2>
|
||||
<span class="webhooks-toggle" id="webhooks-toggle">▼</span>
|
||||
</div>
|
||||
<div class="webhooks-content hidden" id="webhooks-content">
|
||||
<div id="webhook-loading" class="webhook-loading hidden">Loading webhook status...</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||
<!-- Sonarr Webhook -->
|
||||
<div class="webhook-instance">
|
||||
<h3>Sonarr</h3>
|
||||
<div class="webhook-status">
|
||||
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
|
||||
<button id="enable-sonarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||
<button id="test-sonarr-webhook" class="test-webhook-btn hidden">Test</button>
|
||||
</div>
|
||||
<div class="webhook-triggers hidden" id="sonarr-triggers">
|
||||
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade">✗</span></div>
|
||||
</div>
|
||||
<div class="webhook-stats hidden" id="sonarr-stats">
|
||||
<div class="webhook-stats-title">Statistics</div>
|
||||
<div class="webhook-stats-grid">
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radarr Webhook -->
|
||||
<div class="webhook-instance">
|
||||
<h3>Radarr</h3>
|
||||
<div class="webhook-status">
|
||||
<span class="status-indicator" id="radarr-status">○ Disabled</span>
|
||||
<button id="enable-radarr-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||
<button id="test-radarr-webhook" class="test-webhook-btn hidden">Test</button>
|
||||
</div>
|
||||
<div class="webhook-triggers hidden" id="radarr-triggers">
|
||||
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport">✗</span></div>
|
||||
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade">✗</span></div>
|
||||
</div>
|
||||
<div class="webhook-stats hidden" id="radarr-stats">
|
||||
<div class="webhook-stats-title">Statistics</div>
|
||||
<div class="webhook-stats-grid">
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
|
||||
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-message hidden"></div>
|
||||
|
||||
<div id="loading" class="loading hidden">Loading downloads...</div>
|
||||
|
||||
<div class="main-tabs">
|
||||
<div class="tab-bar">
|
||||
@@ -82,7 +144,27 @@
|
||||
|
||||
<div class="tab-panel" id="tab-downloads">
|
||||
<div class="downloads-container">
|
||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||
<div class="downloads-header">
|
||||
<div class="downloads-controls">
|
||||
<label class="download-client-label" for="download-client-filter">Download client:</label>
|
||||
<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">
|
||||
<span id="download-client-selected-text">All clients</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div class="download-client-dropdown" id="download-client-dropdown">
|
||||
<div class="download-client-dropdown-header">
|
||||
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
|
||||
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
|
||||
</div>
|
||||
<div class="download-client-options" id="download-client-options">
|
||||
<!-- Options will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="no-downloads" class="no-downloads hidden">
|
||||
<p>No downloads found for your user.</p>
|
||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||
</div>
|
||||
@@ -90,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||
<div class="tab-panel hidden" id="tab-history">
|
||||
<div class="history-container" id="history-container">
|
||||
<div class="history-header">
|
||||
<div class="history-controls">
|
||||
@@ -104,9 +186,9 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||
<div id="no-history" class="no-history" style="display: none;">
|
||||
<div id="history-loading" class="history-loading hidden">Loading history...</div>
|
||||
<div id="history-error" class="history-error hidden"></div>
|
||||
<div id="no-history" class="no-history hidden">
|
||||
<p>No completed downloads found in this period.</p>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
|
||||
+493
-1
@@ -1,3 +1,8 @@
|
||||
/* ===== Utility Classes ===== */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Splash Screen ===== */
|
||||
.splash-screen {
|
||||
position: fixed;
|
||||
@@ -36,6 +41,7 @@
|
||||
--bg-gradient-end: #d4dee8;
|
||||
|
||||
/* Surfaces */
|
||||
--background: #f5f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f4f7;
|
||||
|
||||
@@ -98,6 +104,7 @@
|
||||
[data-theme="dark"] {
|
||||
--bg-gradient-start: #1a1a2e;
|
||||
--bg-gradient-end: #16213e;
|
||||
--background: #161622;
|
||||
--surface: #1e1e2f;
|
||||
--surface-alt: #2a2a3d;
|
||||
--text-primary: #e0e0e0;
|
||||
@@ -136,6 +143,7 @@
|
||||
[data-theme="mono"] {
|
||||
--bg-gradient-start: #222222;
|
||||
--bg-gradient-end: #333333;
|
||||
--background: #141414;
|
||||
--surface: #1a1a1a;
|
||||
--surface-alt: #252525;
|
||||
--text-primary: #d0d0d0;
|
||||
@@ -370,6 +378,7 @@ body {
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s, background 0.3s;
|
||||
background: var(--surface);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
@@ -659,6 +668,212 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Downloads header and controls */
|
||||
.downloads-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.downloads-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.download-client-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.download-client-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-client-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Multi-select dropdown container */
|
||||
.download-client-filter {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn .dropdown-arrow {
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.download-client-count {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dropdown panel */
|
||||
.download-client-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.download-client-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dropdown header with Select All/Deselect All buttons */
|
||||
.download-client-dropdown-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn-small {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn-small:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Client option row */
|
||||
.download-client-option {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.download-client-option:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.download-client-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.download-client-option-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-client-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-alt);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.download-client-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Client icon */
|
||||
.download-client-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.download-client-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.download-client-icon.fallback {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1193,7 +1408,6 @@ body {
|
||||
text-transform: capitalize;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1203,6 +1417,52 @@ body {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Download client logo in card */
|
||||
.download-header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.download-client-logo-wrapper {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Card-specific logo wrapper positioned at bottom right */
|
||||
.download-card-logo-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.download-client-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.download-client-logo-wrapper.fallback {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 2px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.download-card-logo-wrapper.fallback {
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== Status Button ===== */
|
||||
.status-btn {
|
||||
padding: 4px 12px;
|
||||
@@ -1232,6 +1492,22 @@ body {
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
#status-content {
|
||||
min-height: 150px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
padding: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1516,3 +1792,219 @@ body {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Webhooks Configuration ===== */
|
||||
.webhooks-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.webhooks-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--surface-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.webhooks-header:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.webhooks-header h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Webhooks metrics styling to match status cards */
|
||||
.webhook-stats {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.webhooks-toggle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.webhooks-toggle.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.webhooks-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.webhook-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.webhook-instance {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.webhook-instance:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.webhook-instance h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webhook-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.enabled {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator.disabled {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.enable-webhook-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.enable-webhook-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.enable-webhook-btn:disabled {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-webhook-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--info);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.test-webhook-btn:hover {
|
||||
background: var(--info-hover, var(--info));
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.test-webhook-btn:disabled {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webhook-triggers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.trigger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.trigger-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.trigger-value {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.trigger-value.active {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.trigger-value.inactive {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.webhook-stats-title {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.webhook-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.webhook-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.webhook-stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.webhook-stat-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ 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 verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
@@ -94,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
// API routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
@@ -102,6 +105,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/status', statusRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
// Global error handler
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* Abstract base class for all *arr data retrievers.
|
||||
* Defines the common interface that all retrievers must implement.
|
||||
* This pluggable layer enables future retrieval strategies (e.g., webhook listeners)
|
||||
* to push normalized data directly into the existing cache and SSE system
|
||||
* without touching the poller logic.
|
||||
*/
|
||||
class ArrRetriever {
|
||||
/**
|
||||
* @param {Object} instanceConfig - Configuration for this retriever instance
|
||||
* @param {string} instanceConfig.id - Unique identifier for this instance
|
||||
* @param {string} instanceConfig.name - Display name for this instance
|
||||
* @param {string} instanceConfig.url - Base URL for the *arr API
|
||||
* @param {string} instanceConfig.apiKey - API key for authentication
|
||||
*/
|
||||
constructor(instanceConfig) {
|
||||
if (this.constructor === ArrRetriever) {
|
||||
throw new Error('ArrRetriever is an abstract class and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.id = instanceConfig.id;
|
||||
this.name = instanceConfig.name;
|
||||
this.url = instanceConfig.url;
|
||||
this.apiKey = instanceConfig.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the retriever type identifier (e.g., 'sonarr', 'radarr')
|
||||
* @returns {string} The retriever type
|
||||
*/
|
||||
getRetrieverType() {
|
||||
throw new Error('getRetrieverType() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique instance ID
|
||||
* @returns {string} The instance ID
|
||||
*/
|
||||
getInstanceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from this *arr instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags() {
|
||||
throw new Error('getTags() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from this *arr instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue() {
|
||||
throw new Error('getQueue() must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from this *arr instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize] - Number of records to fetch
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeSeries] - Include series data (Sonarr)
|
||||
* @param {boolean} [options.includeEpisode] - Include episode data (Sonarr)
|
||||
* @param {boolean} [options.includeMovie] - Include movie data (Radarr)
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
throw new Error('getHistory() must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArrRetriever;
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const ArrRetriever = require('./ArrRetriever');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Polling-based Radarr data retriever.
|
||||
* Implements the ArrRetriever interface using direct HTTP polling.
|
||||
*/
|
||||
class PollingRadarrRetriever extends ArrRetriever {
|
||||
constructor(instanceConfig) {
|
||||
super(instanceConfig);
|
||||
}
|
||||
|
||||
getRetrieverType() {
|
||||
return 'radarr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from Radarr instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags() {
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': this.apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from Radarr instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue() {
|
||||
const instanceName = this.name;
|
||||
let page = 1;
|
||||
let allRecords = [];
|
||||
let responseData = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: { includeMovie: true, page, pageSize: 1000 }
|
||||
});
|
||||
responseData = response.data;
|
||||
} catch (error) {
|
||||
console.error(`Radarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
|
||||
allRecords = allRecords.concat(records);
|
||||
page++;
|
||||
} while (
|
||||
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
|
||||
page <= 50
|
||||
);
|
||||
|
||||
return {
|
||||
...responseData,
|
||||
records: allRecords
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from Radarr instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize=100] - Number of records to fetch per page
|
||||
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeMovie=true] - Include movie data
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
const {
|
||||
pageSize = 100,
|
||||
maxPages = 1,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeMovie = true,
|
||||
startDate
|
||||
} = options;
|
||||
|
||||
const instanceName = this.name;
|
||||
let page = 1;
|
||||
let allRecords = [];
|
||||
let responseData = null;
|
||||
|
||||
do {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
includeMovie
|
||||
};
|
||||
|
||||
if (sortKey) params.sortKey = sortKey;
|
||||
if (sortDir) params.sortDir = sortDir;
|
||||
if (startDate) params.startDate = startDate;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params
|
||||
});
|
||||
responseData = response.data;
|
||||
} catch (error) {
|
||||
console.error(`Radarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
|
||||
allRecords = allRecords.concat(records);
|
||||
page++;
|
||||
} while (
|
||||
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
|
||||
page <= maxPages
|
||||
);
|
||||
|
||||
return {
|
||||
...responseData,
|
||||
records: allRecords
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PollingRadarrRetriever;
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const ArrRetriever = require('./ArrRetriever');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Polling-based Sonarr data retriever.
|
||||
* Implements the ArrRetriever interface using direct HTTP polling.
|
||||
*/
|
||||
class PollingSonarrRetriever extends ArrRetriever {
|
||||
constructor(instanceConfig) {
|
||||
super(instanceConfig);
|
||||
}
|
||||
|
||||
getRetrieverType() {
|
||||
return 'sonarr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from Sonarr instance
|
||||
* @returns {Promise<Array>} Array of tag objects
|
||||
*/
|
||||
async getTags() {
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': this.apiKey }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from Sonarr instance
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue() {
|
||||
const instanceName = this.name;
|
||||
let page = 1;
|
||||
let allRecords = [];
|
||||
let responseData = null;
|
||||
|
||||
do {
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true, page, pageSize: 1000 }
|
||||
});
|
||||
responseData = response.data;
|
||||
} catch (error) {
|
||||
console.error(`Sonarr queue fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
|
||||
allRecords = allRecords.concat(records);
|
||||
page++;
|
||||
} while (
|
||||
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === 1000 &&
|
||||
page <= 50
|
||||
);
|
||||
|
||||
return {
|
||||
...responseData,
|
||||
records: allRecords
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from Sonarr instance
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @param {number} [options.pageSize=100] - Number of records to fetch per page
|
||||
* @param {number} [options.maxPages=1] - Maximum pages to fetch (for pagination control)
|
||||
* @param {string} [options.sortKey] - Field to sort by
|
||||
* @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending')
|
||||
* @param {boolean} [options.includeSeries=true] - Include series data
|
||||
* @param {boolean} [options.includeEpisode=true] - Include episode data
|
||||
* @param {string} [options.startDate] - ISO date string for filtering
|
||||
* @returns {Promise<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
const {
|
||||
pageSize = 100,
|
||||
maxPages = 1,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeSeries = true,
|
||||
includeEpisode = true,
|
||||
startDate
|
||||
} = options;
|
||||
|
||||
const instanceName = this.name;
|
||||
let page = 1;
|
||||
let allRecords = [];
|
||||
let responseData = null;
|
||||
|
||||
do {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
includeSeries,
|
||||
includeEpisode
|
||||
};
|
||||
|
||||
if (sortKey) params.sortKey = sortKey;
|
||||
if (sortDir) params.sortDir = sortDir;
|
||||
if (startDate) params.startDate = startDate;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params
|
||||
});
|
||||
responseData = response.data;
|
||||
} catch (error) {
|
||||
console.error(`Sonarr history fetch failed for instance ${instanceName}:`, error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records = responseData.records || (Array.isArray(responseData) ? responseData : []);
|
||||
allRecords = allRecords.concat(records);
|
||||
page++;
|
||||
} while (
|
||||
(responseData.records || (Array.isArray(responseData) ? responseData : [])).length === pageSize &&
|
||||
page <= maxPages
|
||||
);
|
||||
|
||||
return {
|
||||
...responseData,
|
||||
records: allRecords
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PollingSonarrRetriever;
|
||||
@@ -159,6 +159,11 @@ class QBittorrentClient extends DownloadClient {
|
||||
if (this.fallbackThisCycle) {
|
||||
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
|
||||
const torrents = await this.getTorrentsLegacy();
|
||||
this.torrentMap = new Map();
|
||||
for (const torrent of torrents) {
|
||||
this.torrentMap.set(torrent.hash, torrent);
|
||||
}
|
||||
this.lastRid = 0;
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
}
|
||||
|
||||
@@ -170,6 +175,11 @@ class QBittorrentClient extends DownloadClient {
|
||||
this.fallbackThisCycle = true;
|
||||
try {
|
||||
const torrents = await this.getTorrentsLegacy();
|
||||
this.torrentMap = new Map();
|
||||
for (const torrent of torrents) {
|
||||
this.torrentMap.set(torrent.hash, torrent);
|
||||
}
|
||||
this.lastRid = 0;
|
||||
return torrents.map(torrent => this.normalizeDownload(torrent));
|
||||
} catch (fallbackError) {
|
||||
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
|
||||
|
||||
@@ -45,9 +45,10 @@ class SABnzbdClient extends DownloadClient {
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Get both queue and history to provide complete picture
|
||||
const [queueResponse, historyResponse] = await Promise.all([
|
||||
const [queueResponse, historyResponse, clientStatus] = await Promise.all([
|
||||
this.makeRequest({ mode: 'queue' }),
|
||||
this.makeRequest({ mode: 'history', limit: 10 })
|
||||
this.makeRequest({ mode: 'history', limit: 10 }),
|
||||
this.getClientStatus()
|
||||
]);
|
||||
|
||||
const queueData = queueResponse.data;
|
||||
@@ -57,15 +58,27 @@ class SABnzbdClient extends DownloadClient {
|
||||
|
||||
// Process active queue items
|
||||
if (queueData.queue && queueData.queue.slots) {
|
||||
const kbpersec = (queueData.queue && queueData.queue.kbpersec) || (clientStatus && clientStatus.kbpersec) || 0;
|
||||
const globalSpeed = parseFloat(kbpersec) * 1024;
|
||||
|
||||
logToFile(`[SABnzbd:${this.name}] Global Speed: ${globalSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
|
||||
|
||||
for (const slot of queueData.queue.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'queue'));
|
||||
let slotSpeed = 0;
|
||||
if (slot.status === 'Downloading') {
|
||||
slotSpeed = globalSpeed;
|
||||
} else if (slot.status === 'Paused' && slot.kbpersec && parseFloat(slot.kbpersec) > 0) {
|
||||
slotSpeed = globalSpeed;
|
||||
}
|
||||
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
|
||||
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
|
||||
}
|
||||
}
|
||||
|
||||
// Process recent history items (last 10)
|
||||
if (historyData.history && historyData.history.slots) {
|
||||
for (const slot of historyData.history.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'history'));
|
||||
downloads.push(this.normalizeDownload(slot, 'history', 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +115,10 @@ class SABnzbdClient extends DownloadClient {
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(slot, source) {
|
||||
normalizeDownload(slot, source, speed) {
|
||||
const isHistory = source === 'history';
|
||||
|
||||
const finalSpeed = speed !== undefined ? speed : (slot.kbpersec ? parseFloat(slot.kbpersec) * 1024 : 0);
|
||||
|
||||
// Map SABnzbd statuses to normalized status
|
||||
const statusMap = {
|
||||
'Downloading': 'Downloading',
|
||||
@@ -126,10 +140,15 @@ class SABnzbdClient extends DownloadClient {
|
||||
let downloaded = 0;
|
||||
let size = 0;
|
||||
|
||||
if (slot.mb && slot.mbleft !== undefined) {
|
||||
size = slot.mb * 1024 * 1024; // Convert MB to bytes
|
||||
downloaded = (slot.mb - slot.mbleft) * 1024 * 1024;
|
||||
progress = slot.mb > 0 ? ((slot.mb - slot.mbleft) / slot.mb) * 100 : 0;
|
||||
const hasMb = slot.mb !== undefined && slot.mb !== null;
|
||||
const hasMbLeft = slot.mbleft !== undefined && slot.mbleft !== null;
|
||||
const mbValue = hasMb ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = hasMbLeft ? parseFloat(slot.mbleft) : 0;
|
||||
|
||||
if (hasMb && hasMbLeft && mbValue !== 0) {
|
||||
size = mbValue * 1024 * 1024; // Convert MB to bytes
|
||||
downloaded = (mbValue - mbLeftValue) * 1024 * 1024;
|
||||
progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
} else if (slot.size) {
|
||||
// Try to parse size string (e.g., "1.5 GB")
|
||||
const sizeMatch = slot.size.match(/^([\d.]+)\s*(\w+)$/i);
|
||||
@@ -164,10 +183,10 @@ class SABnzbdClient extends DownloadClient {
|
||||
progress: Math.round(progress),
|
||||
size: Math.round(size),
|
||||
downloaded: Math.round(downloaded),
|
||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
||||
speed: finalSpeed,
|
||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||
category: slot.cat || undefined,
|
||||
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
|
||||
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
|
||||
savePath: slot.final_name || undefined,
|
||||
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
||||
arrQueueId: arrInfo.queueId,
|
||||
|
||||
@@ -82,8 +82,10 @@ 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 verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
@@ -252,6 +254,7 @@ function serveIndex(req, res) {
|
||||
// ---------------------------------------------------------------------------
|
||||
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);
|
||||
@@ -260,6 +263,7 @@ app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
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
|
||||
|
||||
+100
-979
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,16 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
||||
|
||||
// Helper to get first Radarr instance (for notification proxy routes)
|
||||
function getFirstRadarrInstance() {
|
||||
const instances = getRadarrInstances();
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return instances[0];
|
||||
}
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
@@ -56,4 +66,174 @@ router.get('/movies', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/radarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Radarr] Failed to fetch notifications:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/radarr/notifications/:id - get specific notification
|
||||
router.get('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/radarr/notifications - create notification
|
||||
router.post('/notifications', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/radarr/notifications/:id - update notification
|
||||
router.put('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/radarr/notifications/:id - delete notification
|
||||
router.delete('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/radarr/notifications/test - test notification
|
||||
router.post('/notifications/test', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Radarr] Failed to test notification:', error.message);
|
||||
if (error.response) {
|
||||
console.error('[Radarr] Test response status:', error.response.status);
|
||||
console.error('[Radarr] Test response data:', error.response.data);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/radarr/notifications/schema - get notification schema
|
||||
router.get('/notifications/schema', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||
}
|
||||
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||
|
||||
const notificationPayload = {
|
||||
name: 'Sofarr',
|
||||
implementation: 'Webhook',
|
||||
configContract: 'WebhookSettings',
|
||||
fields: [
|
||||
{ name: 'url', value: webhookUrl },
|
||||
{ name: 'method', value: 1 },
|
||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||
],
|
||||
onGrab: true,
|
||||
onDownload: true,
|
||||
onUpgrade: true,
|
||||
onImport: true,
|
||||
onRename: false,
|
||||
onHealthIssue: false,
|
||||
onApplicationUpdate: false,
|
||||
onManualInteractionRequired: false
|
||||
};
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification
|
||||
const response = await axios.put(
|
||||
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||
{ ...notificationPayload, id: existingNotification.id },
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
} else {
|
||||
// Create new notification
|
||||
const response = await axios.post(
|
||||
`${instance.url}/api/v3/notification`,
|
||||
notificationPayload,
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Radarr] Failed to configure webhook:', error.message);
|
||||
if (error.response) {
|
||||
console.error('[Radarr] Response status:', error.response.status);
|
||||
console.error('[Radarr] Response data:', error.response.data);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,6 +4,16 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
||||
|
||||
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||
function getFirstSonarrInstance() {
|
||||
const instances = getSonarrInstances();
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return instances[0];
|
||||
}
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
@@ -56,4 +66,174 @@ router.get('/series', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/sonarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Sonarr] Failed to fetch notifications:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/sonarr/notifications/:id - get specific notification
|
||||
router.get('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/sonarr/notifications - create notification
|
||||
router.post('/notifications', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/sonarr/notifications/:id - update notification
|
||||
router.put('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/sonarr/notifications/:id - delete notification
|
||||
router.delete('/notifications/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/sonarr/notifications/test - test notification
|
||||
router.post('/notifications/test', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Sonarr] Failed to test notification:', error.message);
|
||||
if (error.response) {
|
||||
console.error('[Sonarr] Test response status:', error.response.status);
|
||||
console.error('[Sonarr] Test response data:', error.response.data);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/sonarr/notifications/schema - get notification schema
|
||||
router.get('/notifications/schema', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||
}
|
||||
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||
|
||||
const notificationPayload = {
|
||||
name: 'Sofarr',
|
||||
implementation: 'Webhook',
|
||||
configContract: 'WebhookSettings',
|
||||
fields: [
|
||||
{ name: 'url', value: webhookUrl },
|
||||
{ name: 'method', value: 1 },
|
||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||
],
|
||||
onGrab: true,
|
||||
onDownload: true,
|
||||
onUpgrade: true,
|
||||
onImport: true,
|
||||
onRename: false,
|
||||
onHealthIssue: false,
|
||||
onApplicationUpdate: false,
|
||||
onManualInteractionRequired: false
|
||||
};
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification
|
||||
const response = await axios.put(
|
||||
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||
{ ...notificationPayload, id: existingNotification.id },
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
} else {
|
||||
// Create new notification
|
||||
const response = await axios.post(
|
||||
`${instance.url}/api/v3/notification`,
|
||||
notificationPayload,
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sonarr] Failed to configure webhook:', error.message);
|
||||
if (error.response) {
|
||||
console.error('[Sonarr] Response status:', error.response.status);
|
||||
console.error('[Sonarr] Response data:', error.response.data);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
|
||||
// Admin-only status page with cache stats
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const cacheStats = cache.getStats();
|
||||
const uptime = process.uptime();
|
||||
|
||||
// Get webhook metrics
|
||||
const webhookMetrics = getGlobalWebhookMetrics();
|
||||
|
||||
// Check webhook configuration for each service
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||
: false;
|
||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||
: false;
|
||||
|
||||
// Find Sonarr and Radarr metrics from instances
|
||||
const sonarrMetrics = {};
|
||||
const radarrMetrics = {};
|
||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||
if (url.includes('sonarr')) {
|
||||
sonarrMetrics[url] = metrics;
|
||||
} else if (url.includes('radarr')) {
|
||||
radarrMetrics[url] = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
server: {
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
nodeVersion: process.version,
|
||||
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
|
||||
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
|
||||
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
|
||||
},
|
||||
polling: {
|
||||
enabled: POLLING_ENABLED,
|
||||
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
|
||||
lastPoll: getLastPollTimings()
|
||||
},
|
||||
cache: cacheStats,
|
||||
webhooks: {
|
||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const cache = require('../utils/cache');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter.
|
||||
// Sonarr/Radarr send at most one event per action; 60/min per IP is generous.
|
||||
// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited.
|
||||
const webhookLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many webhook requests' }
|
||||
});
|
||||
|
||||
// Valid *arr eventType strings — used for strict input validation.
|
||||
const VALID_EVENT_TYPES = new Set([
|
||||
'Test',
|
||||
'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired',
|
||||
'DownloadFolderImported', 'ImportFailed',
|
||||
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
|
||||
]);
|
||||
|
||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||
// *arr sends a `date` field on every event; we use it as the replay key component.
|
||||
// TTL = 5 minutes; an event replayed after that window is considered fresh.
|
||||
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
||||
const recentEvents = new Map();
|
||||
|
||||
function pruneReplayCache() {
|
||||
const cutoff = Date.now() - REPLAY_WINDOW_MS;
|
||||
for (const [key, ts] of recentEvents) {
|
||||
if (ts < cutoff) recentEvents.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune the replay cache once per minute
|
||||
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||
|
||||
function isReplay(eventType, instanceName, eventDate) {
|
||||
if (!eventDate) return false;
|
||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
||||
if (recentEvents.has(key)) return true;
|
||||
recentEvents.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand
|
||||
const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||
|
||||
// Event classification — determines which cache keys to refresh
|
||||
const QUEUE_EVENTS = new Set([
|
||||
'Grab',
|
||||
'Download',
|
||||
'DownloadFailed',
|
||||
'ManualInteractionRequired'
|
||||
]);
|
||||
|
||||
const HISTORY_EVENTS = new Set([
|
||||
'DownloadFolderImported',
|
||||
'ImportFailed',
|
||||
'EpisodeFileRenamed',
|
||||
'MovieFileRenamed',
|
||||
'EpisodeFileRenamedBySeries'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} True if secret is valid, false otherwise
|
||||
*/
|
||||
function validateWebhookSecret(req) {
|
||||
const expectedSecret = getWebhookSecret();
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
||||
|
||||
if (!expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!providedSecret) {
|
||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (providedSecret !== expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: Invalid webhook secret provided');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a webhook event by refreshing the affected cache and broadcasting SSE.
|
||||
* This is a fire-and-forget background task — callers must respond to the webhook
|
||||
* sender before awaiting this function.
|
||||
*
|
||||
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
||||
*
|
||||
* @param {string} serviceType - 'sonarr' or 'radarr'
|
||||
* @param {string} eventType - the eventType from the *arr webhook payload
|
||||
*/
|
||||
async function processWebhookEvent(serviceType, eventType) {
|
||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||
|
||||
if (!affectsQueue && !affectsHistory) {
|
||||
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`);
|
||||
|
||||
// Ensure retrievers are initialized (idempotent)
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
if (serviceType === 'sonarr') {
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
|
||||
if (affectsQueue) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const sonarrQueues = queuesByType.sonarr || [];
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||
}
|
||||
|
||||
if (affectsHistory) {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
||||
const sonarrHistories = historyByType.sonarr || [];
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`);
|
||||
}
|
||||
} else if (serviceType === 'radarr') {
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
if (affectsQueue) {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
const radarrQueues = queuesByType.radarr || [];
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||
}
|
||||
|
||||
if (affectsHistory) {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 });
|
||||
const radarrHistories = historyByType.radarr || [];
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
|
||||
// pollAllServices() refreshes all data, updates every cache key, and then
|
||||
// iterates pollSubscribers to push fresh payloads to every open SSE connection.
|
||||
// If a poll is already in progress this call is a no-op, but the cache keys
|
||||
// above were already updated so the next broadcast (or dashboard request)
|
||||
// will see fresh data.
|
||||
logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()');
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize the incoming webhook payload.
|
||||
* Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }.
|
||||
*/
|
||||
function validatePayload(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { valid: false, reason: 'Payload must be a JSON object' };
|
||||
}
|
||||
const { eventType, instanceName } = body;
|
||||
if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) {
|
||||
return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' };
|
||||
}
|
||||
if (!VALID_EVENT_TYPES.has(eventType)) {
|
||||
return { valid: false, reason: `Unknown eventType: ${eventType}` };
|
||||
}
|
||||
if (instanceName !== undefined && typeof instanceName !== 'string') {
|
||||
return { valid: false, reason: 'instanceName must be a string if provided' };
|
||||
}
|
||||
const eventDate = body.date || null;
|
||||
return { valid: true, eventType, instanceName: instanceName || null, eventDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhook/sonarr
|
||||
* Receives webhook events from Sonarr instances.
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
*/
|
||||
router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
if (inst) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`);
|
||||
}
|
||||
|
||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||
processWebhookEvent('sonarr', eventType).catch(err => {
|
||||
logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Webhook] Sonarr error: ${error.message}`);
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/webhook/radarr
|
||||
* Receives webhook events from Radarr instances.
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
*/
|
||||
router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const validation = validatePayload(req.body);
|
||||
if (!validation.valid) {
|
||||
logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`);
|
||||
return res.status(400).json({ error: validation.reason });
|
||||
}
|
||||
|
||||
const { eventType, instanceName, eventDate } = validation;
|
||||
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
|
||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||
|
||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
|
||||
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// Phase 5.1: update webhook metrics for polling optimization
|
||||
if (inst) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`);
|
||||
}
|
||||
|
||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||
processWebhookEvent('radarr', eventType).catch(err => {
|
||||
logToFile(`[Webhook] Radarr background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Webhook] Radarr error: ${error.message}`);
|
||||
res.status(200).json({ received: true });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Helper function to extract poster/cover art URL from a movie or series object
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
// Fallback to fanart if no poster
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
// Extract import issues from a Sonarr/Radarr queue record
|
||||
function getImportIssues(queueRecord) {
|
||||
if (!queueRecord) return null;
|
||||
const state = queueRecord.trackedDownloadState;
|
||||
const status = queueRecord.trackedDownloadStatus;
|
||||
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||
const messages = [];
|
||||
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||
for (const sm of queueRecord.statusMessages) {
|
||||
if (sm.messages && sm.messages.length > 0) {
|
||||
messages.push(...sm.messages);
|
||||
} else if (sm.title) {
|
||||
messages.push(sm.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (queueRecord.errorMessage) {
|
||||
messages.push(queueRecord.errorMessage);
|
||||
}
|
||||
if (messages.length === 0) return null;
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Helper to build Sonarr web UI link for a series
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
// Helper to build Radarr web UI link for a movie
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Determine if a download can be blocklisted by the current user
|
||||
// Admins: always true (they have arrQueueId)
|
||||
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
||||
function canBlocklist(download, isAdmin) {
|
||||
if (isAdmin) return true;
|
||||
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
|
||||
const addedOn = new Date(download.addedOn).getTime();
|
||||
const isOldEnough = addedOn < oneHourAgo;
|
||||
const availability = parseFloat(download.availability);
|
||||
const isLowAvailability = availability < 100;
|
||||
return isOldEnough && isLowAvailability;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract episode info from a Sonarr queue/history record.
|
||||
// Returns { season, episode, title } or null if data is missing.
|
||||
function extractEpisode(record) {
|
||||
if (!record) return null;
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
// Find all episodes associated with a download by matching all queue/history records
|
||||
// that share the same title string. Returns sorted array of { season, episode, title }.
|
||||
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of sonarrRecords) {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCoverArt,
|
||||
getImportIssues,
|
||||
getSonarrLink,
|
||||
getRadarrLink,
|
||||
canBlocklist,
|
||||
extractEpisode,
|
||||
gatherEpisodes
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* DownloadBuilder - Aggregates and matches download data from multiple sources.
|
||||
* This service processes cached data from SABnzbd, qBittorrent, Sonarr, and Radarr to build
|
||||
* a unified view of downloads for each user, matching downloads to media metadata via tags.
|
||||
*/
|
||||
|
||||
const DownloadMatcher = require('./DownloadMatcher');
|
||||
|
||||
/**
|
||||
* Builds a unified list of downloads for a user from multiple download clients.
|
||||
* Matches SABnzbd and qBittorrent downloads to Sonarr/Radarr activity using tags.
|
||||
* @param {Object} cacheSnapshot - Cached data from all services
|
||||
* @param {Object} options - User context and metadata maps
|
||||
* @param {string} options.username - Lowercase username for tag matching
|
||||
* @param {string} options.usernameSanitized - Original username
|
||||
* @param {boolean} options.isAdmin - Whether user is admin
|
||||
* @param {boolean} options.showAll - Whether to show all users' downloads
|
||||
* @param {Map} options.seriesMap - Map of seriesId to series object
|
||||
* @param {Map} options.moviesMap - Map of movieId to movie object
|
||||
* @param {Map} options.sonarrTagMap - Map of Sonarr tag IDs to labels
|
||||
* @param {Map} options.radarrTagMap - Map of Radarr tag IDs to labels
|
||||
* @param {Map} options.embyUserMap - Map of Emby users for admin view
|
||||
* @returns {Array} Array of download objects for the user
|
||||
*/
|
||||
function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }) {
|
||||
// Input validation
|
||||
if (!cacheSnapshot || typeof cacheSnapshot !== 'object') {
|
||||
console.error('[DownloadBuilder] Invalid cacheSnapshot provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle null/undefined cache data
|
||||
const sabnzbdQueue = cacheSnapshot.sabnzbdQueue || { data: { queue: { slots: [] } } };
|
||||
const sabnzbdHistory = cacheSnapshot.sabnzbdHistory || { data: { history: { slots: [] } } };
|
||||
const sonarrQueue = cacheSnapshot.sonarrQueue || { data: { records: [] } };
|
||||
const sonarrHistory = cacheSnapshot.sonarrHistory || { data: { records: [] } };
|
||||
const radarrQueue = cacheSnapshot.radarrQueue || { data: { records: [] } };
|
||||
const radarrHistory = cacheSnapshot.radarrHistory || { data: { records: [] } };
|
||||
const qbittorrentTorrents = cacheSnapshot.qbittorrentTorrents || [];
|
||||
|
||||
// Get queue status for SABnzbd
|
||||
const queueStatus = sabnzbdQueue.data?.queue?.status || null;
|
||||
const queueSpeed = sabnzbdQueue.data?.queue?.speed || null;
|
||||
const queueKbpersec = sabnzbdQueue.data?.queue?.kbpersec || null;
|
||||
|
||||
// Build context for matching functions
|
||||
const context = {
|
||||
sonarrQueueRecords: sonarrQueue.data?.records || [],
|
||||
sonarrHistoryRecords: sonarrHistory.data?.records || [],
|
||||
radarrQueueRecords: radarrQueue.data?.records || [],
|
||||
radarrHistoryRecords: radarrHistory.data?.records || [],
|
||||
seriesMap: seriesMap || new Map(),
|
||||
moviesMap: moviesMap || new Map(),
|
||||
sonarrTagMap: sonarrTagMap || new Map(),
|
||||
radarrTagMap: radarrTagMap || new Map(),
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap: embyUserMap || new Map(),
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec
|
||||
};
|
||||
|
||||
// Match all download sources
|
||||
const userDownloads = [];
|
||||
const seenDownloadKeys = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
for (const dl of sabMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sabnzbdHistory.data?.history?.slots) {
|
||||
const sabHistoryMatches = DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
for (const dl of sabHistoryMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
}
|
||||
}
|
||||
|
||||
return userDownloads;
|
||||
} catch (error) {
|
||||
console.error('[DownloadBuilder] Error building user downloads:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildUserDownloads
|
||||
};
|
||||
@@ -0,0 +1,561 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
/**
|
||||
* DownloadMatcher - Matches download client data to Sonarr/Radarr activity.
|
||||
* Contains logic for matching SABnzbd slots and qBittorrent torrents to media metadata
|
||||
* via download IDs and title matching.
|
||||
*/
|
||||
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const TagMatcher = require('./TagMatcher');
|
||||
const DownloadAssembler = require('./DownloadAssembler');
|
||||
|
||||
/**
|
||||
* Builds a Map of series metadata from Sonarr queue and history records.
|
||||
* @param {Array} queueRecords - Sonarr queue records
|
||||
* @param {Array} historyRecords - Sonarr history records
|
||||
* @returns {Map} Map of seriesId to series object
|
||||
*/
|
||||
function buildSeriesMapFromRecords(queueRecords, historyRecords) {
|
||||
const seriesMap = new Map();
|
||||
for (const r of queueRecords) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
for (const r of historyRecords) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
return seriesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Map of movie metadata from Radarr queue and history records.
|
||||
* @param {Array} queueRecords - Radarr queue records
|
||||
* @param {Array} historyRecords - Radarr history records
|
||||
* @returns {Map} Map of movieId to movie object
|
||||
*/
|
||||
function buildMoviesMapFromRecords(queueRecords, historyRecords) {
|
||||
const moviesMap = new Map();
|
||||
for (const r of queueRecords) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
for (const r of historyRecords) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
return moviesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the status and speed for a SABnzbd slot based on queue state.
|
||||
* @param {Object} slot - SABnzbd queue slot
|
||||
* @param {string} queueStatus - Overall queue status (e.g., 'Paused')
|
||||
* @param {string} queueSpeed - Queue speed string
|
||||
* @param {string} queueKbpersec - Queue speed in KB/s
|
||||
* @returns {Object} Object with status and speed properties
|
||||
*/
|
||||
function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) {
|
||||
if (queueStatus === 'Paused') {
|
||||
return { status: 'Paused', speed: '0' };
|
||||
}
|
||||
return {
|
||||
status: slot.status || 'Unknown',
|
||||
speed: queueSpeed || queueKbpersec || '0'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches SABnzbd queue slots to Sonarr/Radarr activity using download IDs and title matching.
|
||||
* @param {Array} slots - SABnzbd queue slots
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
function matchSabSlots(slots, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
for (const slot of slots) {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
|
||||
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
||||
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;
|
||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
|
||||
// Also check HISTORY by downloadId
|
||||
if (!sonarrMatch && sabDownloadId) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
||||
}
|
||||
if (!radarrMatch && sabDownloadId) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.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 = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Also check HISTORY (completed downloads) if no queue match
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: '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;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbleft,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
*/
|
||||
function matchSabHistory(slots, context) {
|
||||
const {
|
||||
sonarrHistoryRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
} = context;
|
||||
|
||||
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 => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
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) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches qBittorrent torrents to Sonarr/Radarr activity using title matching.
|
||||
* @param {Array} torrents - qBittorrent torrent list
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
function matchTorrents(torrents, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
for (const torrent of torrents) {
|
||||
const torrentName = torrent.name || '';
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
let matchedAny = false;
|
||||
|
||||
const sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: '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;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
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) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
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) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
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) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrHistoryMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
getSlotStatusAndSpeed,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('../utils/cache');
|
||||
|
||||
// Return all resolved tag labels for a series/movie.
|
||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
||||
// For Sonarr: tags are objects with a label property.
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) {
|
||||
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
}
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
// Return the tag label that matches the current username, or null.
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Check if a tag matches the username: exact match first, then sanitized match
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
// Exact match (handles users whose tags weren't mangled)
|
||||
if (tagLower === username) return true;
|
||||
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
// Build map: both raw lowercase and sanitized form -> display name
|
||||
const map = new Map();
|
||||
for (const u of response.data) {
|
||||
const name = u.Name || '';
|
||||
map.set(name.toLowerCase(), name);
|
||||
map.set(sanitizeTagLabel(name), name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each tag label: matched to a known Emby user, or unmatched.
|
||||
// Returns array of { label, matchedUser: string|null }
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser: displayName };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractAllTags,
|
||||
extractUserTag,
|
||||
sanitizeTagLabel,
|
||||
tagMatchesUser,
|
||||
getEmbyUsers,
|
||||
buildTagBadges
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in a Sonarr/Radarr instance.
|
||||
* @param {Object} instance - The Sonarr/Radarr instance config
|
||||
* @param {string} type - 'Sonarr' or 'Radarr'
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkWebhookConfigured(instance, type) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
const notifications = response.data || [];
|
||||
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check ${type} webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate webhook metrics for a service type.
|
||||
* @param {Object} metricsMap - Map of instance URLs to their metrics
|
||||
* @param {boolean} configured - Whether the service is configured
|
||||
* @returns {Object|null} Aggregated metrics or null if not configured
|
||||
*/
|
||||
function aggregateMetrics(metricsMap, configured) {
|
||||
const values = Object.values(metricsMap);
|
||||
if (values.length === 0) {
|
||||
// Return default metrics if configured but no events yet
|
||||
return configured ? {
|
||||
enabled: true,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0,
|
||||
lastEvent: null
|
||||
} : null;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
|
||||
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
|
||||
lastEvent: values.reduce((latest, m) => {
|
||||
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
|
||||
}, 0)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebhookConfigured,
|
||||
aggregateMetrics
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const { logToFile } = require('./logger');
|
||||
const cache = require('./cache');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('./config');
|
||||
|
||||
// Import retriever classes
|
||||
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
||||
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
||||
|
||||
// Retriever type mapping
|
||||
const retrieverClasses = {
|
||||
sonarr: PollingSonarrRetriever,
|
||||
radarr: PollingRadarrRetriever
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton registry for *arr data retrievers
|
||||
*/
|
||||
const arrRetrieverRegistry = {
|
||||
retrievers: new Map(),
|
||||
initialized: false,
|
||||
|
||||
/**
|
||||
* Initialize all configured *arr retrievers
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...');
|
||||
|
||||
// Get all instance configurations
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// Create retriever instances
|
||||
const instanceConfigs = [
|
||||
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
|
||||
];
|
||||
|
||||
for (const config of instanceConfigs) {
|
||||
try {
|
||||
const RetrieverClass = retrieverClasses[config.type];
|
||||
if (!RetrieverClass) {
|
||||
logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retriever = new RetrieverClass(config);
|
||||
const uniqueKey = `${config.type}:${config.id}`;
|
||||
this.retrievers.set(uniqueKey, retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all registered retrievers
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getAllRetrievers() {
|
||||
return Array.from(this.retrievers.values());
|
||||
},
|
||||
|
||||
/**
|
||||
* Get retriever by instance ID
|
||||
* @param {string} instanceId - The instance ID
|
||||
* @returns {ArrRetriever|null} Retriever instance or null if not found
|
||||
*/
|
||||
getRetriever(instanceId) {
|
||||
return this.retrievers.get(instanceId) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get retrievers by type
|
||||
* @param {string} type - Retriever type ('sonarr', 'radarr')
|
||||
* @returns {Array<ArrRetriever>} Array of retriever instances
|
||||
*/
|
||||
getRetrieversByType(type) {
|
||||
return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of tag results with instance info
|
||||
*/
|
||||
async getAllTags() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch tags from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`);
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get queue from all retrievers
|
||||
* @returns {Promise<Array<Object>>} Array of queue results with instance info
|
||||
*/
|
||||
async getAllQueues() {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch queues from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`);
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get history from all retrievers
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Array<Object>>} Array of history results with instance info
|
||||
*/
|
||||
async getAllHistory(options = {}) {
|
||||
const retrievers = this.getAllRetrievers();
|
||||
if (retrievers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch history from all retrievers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
retrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags grouped by retriever type
|
||||
* @returns {Promise<Object>} Tags grouped by retriever type (array of { instance, data } objects)
|
||||
*/
|
||||
async getTagsByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrTags = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrTags = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const tags = await retriever.getTags();
|
||||
return { instance: retriever.getInstanceId(), data: tags };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrTags
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get queue grouped by retriever type
|
||||
* @returns {Promise<Object>} Queue grouped by retriever type
|
||||
*/
|
||||
async getQueuesByType() {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrQueues = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrQueues = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const queue = await retriever.getQueue();
|
||||
return { instance: retriever.getInstanceId(), data: queue };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrQueues
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get history grouped by retriever type
|
||||
* @param {Object} options - Optional parameters for history fetch
|
||||
* @returns {Promise<Object>} History grouped by retriever type
|
||||
*/
|
||||
async getHistoryByType(options = {}) {
|
||||
const sonarrRetrievers = this.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = this.getRetrieversByType('radarr');
|
||||
|
||||
const sonarrHistory = await Promise.allSettled(
|
||||
sonarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const radarrHistory = await Promise.allSettled(
|
||||
radarrRetrievers.map(async (retriever) => {
|
||||
try {
|
||||
const history = await retriever.getHistory(options);
|
||||
return { instance: retriever.getInstanceId(), data: history };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`);
|
||||
return { instance: retriever.getInstanceId(), data: { records: [] } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
sonarr: sonarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value),
|
||||
radarr: radarrHistory
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||
*/
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tag matches the username: exact match first, then sanitized match
|
||||
*/
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
const usernameLower = username.toLowerCase();
|
||||
// Exact match
|
||||
if (tagLower === usernameLower) return true;
|
||||
// Sanitized match
|
||||
if (tagLower === sanitizeTagLabel(usernameLower)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matching / aggregation helper function to compare a download item and an *arr item.
|
||||
*/
|
||||
function matchDownload(download, arrItem, username, tagMap) {
|
||||
if (!download || !arrItem) return false;
|
||||
|
||||
// 1. First attempt an exact ID match using the stable fields that exist in the fetched data
|
||||
if (download.arrInfo) {
|
||||
// Sonarr stable IDs
|
||||
if (download.arrInfo.episodeFileId !== undefined && arrItem.episodeFileId !== undefined) {
|
||||
if (download.arrInfo.episodeFileId === arrItem.episodeFileId) return true;
|
||||
}
|
||||
if (download.arrInfo.episodeId !== undefined && arrItem.episodeId !== undefined) {
|
||||
if (download.arrInfo.episodeId === arrItem.episodeId) return true;
|
||||
}
|
||||
if (download.arrInfo.seriesId !== undefined && arrItem.seriesId !== undefined) {
|
||||
if (download.arrInfo.seriesId === arrItem.seriesId) return true;
|
||||
}
|
||||
|
||||
// Radarr stable IDs
|
||||
if (download.arrInfo.movieFileId !== undefined && arrItem.movieFileId !== undefined) {
|
||||
if (download.arrInfo.movieFileId === arrItem.movieFileId) return true;
|
||||
}
|
||||
if (download.arrInfo.movieId !== undefined && arrItem.movieId !== undefined) {
|
||||
if (download.arrInfo.movieId === arrItem.movieId) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Only fall back to the existing title + tag string matching if no ID match is possible
|
||||
const dlTitle = (download.title || '').toLowerCase();
|
||||
const arrTitle = (arrItem.title || arrItem.sourceTitle || '').toLowerCase();
|
||||
const titleMatches = dlTitle && arrTitle && (dlTitle.includes(arrTitle) || arrTitle.includes(dlTitle));
|
||||
|
||||
if (!titleMatches) return false;
|
||||
|
||||
// Preserve the existing lowercase-username tag logic exactly
|
||||
if (!username) return true;
|
||||
|
||||
const getLabels = (item) => {
|
||||
if (!item) return [];
|
||||
const tags = item.tags || (item.series && item.series.tags) || (item.movie && item.movie.tags) || [];
|
||||
return tags.map(t => {
|
||||
if (typeof t === 'object' && t !== null) {
|
||||
return t.label || t.name;
|
||||
}
|
||||
if (tagMap && tagMap.has && tagMap.has(t)) {
|
||||
return tagMap.get(t);
|
||||
}
|
||||
|
||||
// Try resolving from cache as fallback
|
||||
const cachedSonarrTags = cache.get('poll:sonarr-tags') || [];
|
||||
const cachedRadarrTags = cache.get('poll:radarr-tags') || [];
|
||||
const allCachedTags = [...cachedSonarrTags, ...cachedRadarrTags];
|
||||
const found = allCachedTags.find(tag => tag && tag.id === t);
|
||||
if (found) return found.label || found.name;
|
||||
|
||||
return t;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const dlTags = getLabels(download);
|
||||
const arrTags = getLabels(arrItem);
|
||||
const allTags = [...dlTags, ...arrTags];
|
||||
|
||||
return allTags.some(tag => tagMatchesUser(tag, username));
|
||||
}
|
||||
|
||||
// Attach matching helper functions to the registry object
|
||||
arrRetrieverRegistry.matchDownload = matchDownload;
|
||||
arrRetrieverRegistry.matchDownloadToArr = matchDownload;
|
||||
arrRetrieverRegistry.aggregateMatch = matchDownload;
|
||||
arrRetrieverRegistry.matchingHelper = matchDownload;
|
||||
arrRetrieverRegistry.compareDownloadAndArr = matchDownload;
|
||||
|
||||
module.exports = arrRetrieverRegistry;
|
||||
@@ -72,4 +72,64 @@ class MemoryCache {
|
||||
|
||||
const cache = new MemoryCache();
|
||||
|
||||
// Webhook metrics for polling optimization
|
||||
// These are stored separately from regular cache entries
|
||||
const webhookMetrics = {
|
||||
// Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped }
|
||||
instances: new Map(),
|
||||
// Global metrics
|
||||
lastGlobalWebhookTimestamp: null,
|
||||
totalWebhookEventsReceived: 0
|
||||
};
|
||||
|
||||
function getWebhookMetrics(instanceUrl) {
|
||||
if (!instanceUrl) return null;
|
||||
return webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
}
|
||||
|
||||
function updateWebhookMetrics(instanceUrl) {
|
||||
const now = Date.now();
|
||||
webhookMetrics.lastGlobalWebhookTimestamp = now;
|
||||
webhookMetrics.totalWebhookEventsReceived++;
|
||||
|
||||
if (instanceUrl) {
|
||||
const metrics = webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
metrics.lastWebhookTimestamp = now;
|
||||
metrics.eventsReceived++;
|
||||
webhookMetrics.instances.set(instanceUrl, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementPollsSkipped(instanceUrl) {
|
||||
if (instanceUrl) {
|
||||
const metrics = webhookMetrics.instances.get(instanceUrl) || {
|
||||
lastWebhookTimestamp: null,
|
||||
eventsReceived: 0,
|
||||
pollsSkipped: 0
|
||||
};
|
||||
metrics.pollsSkipped++;
|
||||
webhookMetrics.instances.set(instanceUrl, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalWebhookMetrics() {
|
||||
return {
|
||||
lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp,
|
||||
totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived,
|
||||
instances: Object.fromEntries(webhookMetrics.instances)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = cache;
|
||||
module.exports.getWebhookMetrics = getWebhookMetrics;
|
||||
module.exports.updateWebhookMetrics = updateWebhookMetrics;
|
||||
module.exports.incrementPollsSkipped = incrementPollsSkipped;
|
||||
module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics;
|
||||
|
||||
@@ -114,6 +114,14 @@ function getRtorrentInstances() {
|
||||
);
|
||||
}
|
||||
|
||||
function getWebhookSecret() {
|
||||
return process.env.SOFARR_WEBHOOK_SECRET || '';
|
||||
}
|
||||
|
||||
function getSofarrBaseUrl() {
|
||||
return process.env.SOFARR_BASE_URL || '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
@@ -121,6 +129,8 @@ module.exports = {
|
||||
getQbittorrentInstances,
|
||||
getTransmissionInstances,
|
||||
getRtorrentInstances,
|
||||
getWebhookSecret,
|
||||
getSofarrBaseUrl,
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
@@ -63,8 +63,9 @@ class DownloadClientRegistry {
|
||||
}
|
||||
|
||||
const client = new ClientClass(config);
|
||||
this.clients.set(config.id, client);
|
||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
|
||||
const uniqueKey = `${config.type}:${config.id}`;
|
||||
this.clients.set(uniqueKey, client);
|
||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
||||
}
|
||||
|
||||
+240
-26
@@ -1,12 +1,26 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
|
||||
// Cache TTL for recent-history data: 5 minutes.
|
||||
// History changes slowly compared to active downloads.
|
||||
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Staged loading configuration
|
||||
const INITIAL_PAGE_SIZE = 100;
|
||||
const MAX_TOTAL_RECORDS = 1000;
|
||||
const MAX_PAGES = 10; // 10 pages * 100 records = 1000 total
|
||||
|
||||
// Background fetch state to prevent concurrent fetches
|
||||
const backgroundFetchState = {
|
||||
sonarr: { inProgress: false, lastFetchTime: 0 },
|
||||
radarr: { inProgress: false, lastFetchTime: 0 }
|
||||
};
|
||||
|
||||
// Event subscribers for history updates
|
||||
const historyUpdateSubscribers = new Set();
|
||||
|
||||
// Sonarr event types that represent a successful import
|
||||
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||
// Sonarr event types that represent a failed import
|
||||
@@ -18,29 +32,43 @@ const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||
/**
|
||||
* Fetch recent history records from all Sonarr instances for the given date window.
|
||||
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
||||
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchSonarrHistory(since) {
|
||||
const cacheKey = 'history:sonarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
// Only trigger background refresh if cache is incomplete (less than max records)
|
||||
if (!backgroundFetchState.sonarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
|
||||
triggerBackgroundSonarrFetch(since);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Ensure retrievers are initialized
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const instances = getSonarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
||||
|
||||
// Stage 1: Fetch initial batch (100 records)
|
||||
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
const response = await retriever.getHistory({
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
maxPages: 1,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.series) r.series._instanceUrl = inst.url;
|
||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||
@@ -56,34 +84,118 @@ async function fetchSonarrHistory(since) {
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
|
||||
// Stage 2: Trigger background fetch for remaining records
|
||||
triggerBackgroundSonarrFetch(since);
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger background fetch for remaining Sonarr history records.
|
||||
* Uses the retriever's built-in pagination to fetch up to 1000 records.
|
||||
*/
|
||||
async function triggerBackgroundSonarrFetch(since) {
|
||||
if (backgroundFetchState.sonarr.inProgress) return;
|
||||
|
||||
// Debounce: don't fetch if we fetched within the last minute
|
||||
const now = Date.now();
|
||||
if (now - backgroundFetchState.sonarr.lastFetchTime < 60000) return;
|
||||
|
||||
backgroundFetchState.sonarr.inProgress = true;
|
||||
backgroundFetchState.sonarr.lastFetchTime = now;
|
||||
|
||||
try {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
const instances = getSonarrInstances();
|
||||
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
||||
|
||||
// Fetch all records up to MAX_PAGES using built-in pagination
|
||||
const results = await Promise.all(sonarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await retriever.getHistory({
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
maxPages: MAX_PAGES,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.series) r.series._instanceUrl = inst.url;
|
||||
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Sonarr background fetch ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const allRecords = results.flat();
|
||||
|
||||
// Only update cache if we got records (don't overwrite with empty data on failure)
|
||||
if (allRecords.length > 0) {
|
||||
cache.set('history:sonarr', allRecords, HISTORY_CACHE_TTL);
|
||||
|
||||
// Emit SSE event for history update
|
||||
emitHistoryUpdate('sonarr');
|
||||
|
||||
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Sonarr records`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[HistoryFetcher] Background Sonarr fetch error:', err.message);
|
||||
} finally {
|
||||
backgroundFetchState.sonarr.inProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent history records from all Radarr instances for the given date window.
|
||||
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||
* Uses staged loading: fetches 100 records immediately, then background-fetches up to 1000.
|
||||
* @param {Date} since - Only include records on or after this date
|
||||
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
||||
*/
|
||||
async function fetchRadarrHistory(since) {
|
||||
const cacheKey = 'history:radarr';
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
// Only trigger background refresh if cache is incomplete (less than max records)
|
||||
if (!backgroundFetchState.radarr.inProgress && cached.length < MAX_TOTAL_RECORDS) {
|
||||
triggerBackgroundRadarrFetch(since);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Ensure retrievers are initialized
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const instances = getRadarrInstances();
|
||||
const results = await Promise.all(instances.map(async inst => {
|
||||
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
||||
|
||||
// Stage 1: Fetch initial batch (100 records)
|
||||
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: {
|
||||
pageSize: 100,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
}
|
||||
const response = await retriever.getHistory({
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
maxPages: 1,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response.data && response.data.records) || [];
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||
@@ -99,9 +211,109 @@ async function fetchRadarrHistory(since) {
|
||||
|
||||
const flat = results.flat();
|
||||
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||
|
||||
// Stage 2: Trigger background fetch for remaining records
|
||||
triggerBackgroundRadarrFetch(since);
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger background fetch for remaining Radarr history records.
|
||||
* Uses the retriever's built-in pagination to fetch up to 1000 records.
|
||||
*/
|
||||
async function triggerBackgroundRadarrFetch(since) {
|
||||
if (backgroundFetchState.radarr.inProgress) return;
|
||||
|
||||
// Debounce: don't fetch if we fetched within the last minute
|
||||
const now = Date.now();
|
||||
if (now - backgroundFetchState.radarr.lastFetchTime < 60000) return;
|
||||
|
||||
backgroundFetchState.radarr.inProgress = true;
|
||||
backgroundFetchState.radarr.lastFetchTime = now;
|
||||
|
||||
try {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
const instances = getRadarrInstances();
|
||||
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
||||
|
||||
// Fetch all records up to MAX_PAGES using built-in pagination
|
||||
const results = await Promise.all(radarrRetrievers.map(async (retriever) => {
|
||||
const inst = instances.find(i => i.id === retriever.getInstanceId());
|
||||
if (!inst) return [];
|
||||
|
||||
try {
|
||||
const response = await retriever.getHistory({
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
maxPages: MAX_PAGES,
|
||||
sortKey: 'date',
|
||||
sortDir: 'descending',
|
||||
includeMovie: true,
|
||||
startDate: since.toISOString()
|
||||
});
|
||||
const records = (response && response.records) || [];
|
||||
return records.map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||
r._instanceUrl = inst.url;
|
||||
r._instanceName = inst.name || inst.id;
|
||||
return r;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HistoryFetcher] Radarr background fetch ${inst.id} error:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
|
||||
const allRecords = results.flat();
|
||||
|
||||
// Only update cache if we got records (don't overwrite with empty data on failure)
|
||||
if (allRecords.length > 0) {
|
||||
cache.set('history:radarr', allRecords, HISTORY_CACHE_TTL);
|
||||
|
||||
// Emit SSE event for history update
|
||||
emitHistoryUpdate('radarr');
|
||||
|
||||
console.log(`[HistoryFetcher] Background fetch complete: ${allRecords.length} Radarr records`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[HistoryFetcher] Background Radarr fetch error:', err.message);
|
||||
} finally {
|
||||
backgroundFetchState.radarr.inProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to history update events.
|
||||
* @param {Function} callback - Function to call when history is updated
|
||||
*/
|
||||
function onHistoryUpdate(callback) {
|
||||
historyUpdateSubscribers.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from history update events.
|
||||
* @param {Function} callback - Function to remove from subscribers
|
||||
*/
|
||||
function offHistoryUpdate(callback) {
|
||||
historyUpdateSubscribers.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit SSE event for history update.
|
||||
* Notifies all subscribers when history cache is updated.
|
||||
*/
|
||||
function emitHistoryUpdate(type) {
|
||||
console.log(`[HistoryFetcher] History updated for ${type}, notifying ${historyUpdateSubscribers.size} subscribers`);
|
||||
historyUpdateSubscribers.forEach(callback => {
|
||||
try {
|
||||
callback(type);
|
||||
} catch (err) {
|
||||
console.error('[HistoryFetcher] Error in history update subscriber:', err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Sonarr history record's event type.
|
||||
* @param {string} eventType
|
||||
@@ -139,5 +351,7 @@ module.exports = {
|
||||
classifySonarrEvent,
|
||||
classifyRadarrEvent,
|
||||
invalidateHistoryCache,
|
||||
onHistoryUpdate,
|
||||
offHistoryUpdate,
|
||||
HISTORY_CACHE_TTL
|
||||
};
|
||||
|
||||
+146
-91
@@ -2,6 +2,7 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
@@ -13,6 +14,13 @@ const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false'
|
||||
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
|
||||
const POLLING_ENABLED = POLL_INTERVAL > 0;
|
||||
|
||||
// Webhook fallback timeout in minutes (default 10)
|
||||
const WEBHOOK_FALLBACK_TIMEOUT_MINUTES = parseInt(process.env.WEBHOOK_FALLBACK_TIMEOUT, 10) || 10;
|
||||
const WEBHOOK_FALLBACK_TIMEOUT_MS = WEBHOOK_FALLBACK_TIMEOUT_MINUTES * 60 * 1000;
|
||||
|
||||
// Webhook poll interval multiplier when webhooks are active (default 3x)
|
||||
const WEBHOOK_POLL_INTERVAL_MULTIPLIER = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MULTIPLIER, 10) || 3;
|
||||
|
||||
let polling = false;
|
||||
let lastPollTimings = null;
|
||||
|
||||
@@ -29,6 +37,42 @@ async function timed(label, fn) {
|
||||
return { label, result, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
// Helper function to determine if instance polling should be skipped
|
||||
function shouldSkipInstancePolling(instances, instanceType) {
|
||||
if (!instances || instances.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let allInstancesHaveRecentWebhooks = true;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const instance of instances) {
|
||||
const metrics = cache.getWebhookMetrics(instance.url);
|
||||
|
||||
// Skip polling if:
|
||||
// 1. Webhook events have been received (eventsReceived > 0)
|
||||
// 2. Last webhook was recent (within fallback timeout)
|
||||
// 3. Webhook has been enabled (we have metrics)
|
||||
const hasWebhookActivity = metrics && metrics.eventsReceived > 0;
|
||||
const isRecent = metrics && metrics.lastWebhookTimestamp && (now - metrics.lastWebhookTimestamp) < WEBHOOK_FALLBACK_TIMEOUT_MS;
|
||||
|
||||
if (hasWebhookActivity && isRecent) {
|
||||
skippedCount++;
|
||||
cache.incrementPollsSkipped(instance.url);
|
||||
} else {
|
||||
allInstancesHaveRecentWebhooks = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allInstancesHaveRecentWebhooks && skippedCount > 0) {
|
||||
console.log(`[Poller] Skipping ${instanceType} polling for ${skippedCount} instance(s) with active webhooks`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pollAllServices() {
|
||||
if (polling) {
|
||||
console.log('[Poller] Previous poll still running, skipping');
|
||||
@@ -38,70 +82,57 @@ async function pollAllServices() {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Ensure download clients are initialized
|
||||
// Ensure download clients and *arr retrievers are initialized
|
||||
await initializeClients();
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
||||
const globalMetrics = cache.getGlobalWebhookMetrics();
|
||||
const now = Date.now();
|
||||
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
||||
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
||||
|
||||
if (fallbackTriggered) {
|
||||
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
|
||||
}
|
||||
|
||||
// Determine which instances should be polled based on webhook activity
|
||||
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
||||
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
||||
|
||||
// All fetches in parallel, each individually timed
|
||||
const results = await Promise.all([
|
||||
timed('Download Clients', async () => {
|
||||
const downloadsByType = await getDownloadsByClientType();
|
||||
return downloadsByType;
|
||||
}),
|
||||
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
shouldPollSonarr ? timed('Sonarr Tags', async () => {
|
||||
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
||||
return tagsByType.sonarr || [];
|
||||
}) : timed('Sonarr Tags', async () => []),
|
||||
shouldPollSonarr ? timed('Sonarr Queue', async () => {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
return queuesByType.sonarr || [];
|
||||
}) : timed('Sonarr Queue', async () => []),
|
||||
shouldPollSonarr ? timed('Sonarr History', async () => {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
|
||||
return historyByType.sonarr || [];
|
||||
}) : timed('Sonarr History', async () => []),
|
||||
shouldPollRadarr ? timed('Radarr Queue', async () => {
|
||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||
return queuesByType.radarr || [];
|
||||
}) : timed('Radarr Queue', async () => []),
|
||||
shouldPollRadarr ? timed('Radarr History', async () => {
|
||||
const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 50 });
|
||||
return historyByType.radarr || [];
|
||||
}) : timed('Radarr History', async () => []),
|
||||
shouldPollRadarr ? timed('Radarr Tags', async () => {
|
||||
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
||||
return tagsByType.radarr || [];
|
||||
}) : timed('Radarr Tags', async () => []),
|
||||
]);
|
||||
|
||||
const [
|
||||
@@ -147,10 +178,12 @@ async function pollAllServices() {
|
||||
cat: d.category,
|
||||
labels: d.tags.join(','),
|
||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||
raw: d.raw
|
||||
raw: d.raw,
|
||||
instanceId: d.instanceId,
|
||||
instanceName: d.instanceName
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
const sabHistoryLegacy = {
|
||||
slots: sabHistory.map(d => ({
|
||||
nzo_id: d.id,
|
||||
@@ -160,7 +193,9 @@ async function pollAllServices() {
|
||||
cat: d.category,
|
||||
labels: d.tags.join(','),
|
||||
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
|
||||
raw: d.raw
|
||||
raw: d.raw,
|
||||
instanceId: d.instanceId,
|
||||
instanceName: d.instanceName
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -189,43 +224,63 @@ async function pollAllServices() {
|
||||
cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL);
|
||||
|
||||
// Sonarr
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
if (shouldPollSonarr) {
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
} else {
|
||||
// Extend TTL of existing cached data when polling is skipped
|
||||
const existingSonarrTags = cache.get('poll:sonarr-tags');
|
||||
const existingSonarrQueue = cache.get('poll:sonarr-queue');
|
||||
const existingSonarrHistory = cache.get('poll:sonarr-history');
|
||||
if (existingSonarrTags) cache.set('poll:sonarr-tags', existingSonarrTags, cacheTTL);
|
||||
if (existingSonarrQueue) cache.set('poll:sonarr-queue', existingSonarrQueue, cacheTTL);
|
||||
if (existingSonarrHistory) cache.set('poll:sonarr-history', existingSonarrHistory, cacheTTL);
|
||||
}
|
||||
|
||||
// Radarr
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||
if (shouldPollRadarr) {
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
const key = inst ? inst.apiKey : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
r._instanceUrl = url;
|
||||
r._instanceKey = key;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||
} else {
|
||||
// Extend TTL of existing cached data when polling is skipped
|
||||
const existingRadarrQueue = cache.get('poll:radarr-queue');
|
||||
const existingRadarrHistory = cache.get('poll:radarr-history');
|
||||
const existingRadarrTags = cache.get('poll:radarr-tags');
|
||||
if (existingRadarrQueue) cache.set('poll:radarr-queue', existingRadarrQueue, cacheTTL);
|
||||
if (existingRadarrHistory) cache.set('poll:radarr-history', existingRadarrHistory, cacheTTL);
|
||||
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
|
||||
}
|
||||
|
||||
// qBittorrent (already set above in download clients section)
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ function mapTorrentToDownload(torrent) {
|
||||
return {
|
||||
type: 'torrent',
|
||||
title: torrent.name,
|
||||
client: 'qbittorrent',
|
||||
instanceId: torrent.instanceId,
|
||||
instanceName: torrent.instanceName,
|
||||
status: status,
|
||||
progress: progress.toFixed(1),
|
||||
|
||||
+41
-11
@@ -38,10 +38,24 @@ tests/
|
||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
│ ├── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||
│ └── dashboard.test.js # Pure helper functions: getCoverArt, extractAllTags,
|
||||
│ # extractUserTag, sanitizeTagLabel, tagMatchesUser,
|
||||
│ # getImportIssues, getSonarrLink, getRadarrLink,
|
||||
│ # canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges
|
||||
└── integration/
|
||||
├── health.test.js # GET /health and /ready endpoints
|
||||
└── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
||||
├── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
||||
│ # replay protection, metrics, security assertions
|
||||
├── dashboard.test.js # GET /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll,
|
||||
│ # paused queue, history, importIssues), GET /status,
|
||||
│ # GET /webhook-metrics, GET /cover-art, POST /blocklist-search
|
||||
├── emby.test.js # GET /sessions, /users, /users/:id, /session/:id/user
|
||||
└── arrRoutes.test.js # Sonarr + Radarr: queue, history, series/movies, notifications
|
||||
# CRUD, /test, /schema, /sofarr-webhook (create + update)
|
||||
# SABnzbd: queue, history
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -54,14 +68,30 @@ tests/
|
||||
|
||||
## Coverage targets
|
||||
|
||||
The tested files meet these per-file minimums (enforced in CI):
|
||||
Global thresholds (enforced in CI via `vitest.config.js`):
|
||||
|
||||
| File | Lines | Branches |
|
||||
|---|---|---|
|
||||
| `server/app.js` | 85% | 65% |
|
||||
| `server/routes/auth.js` | 85% | 70% |
|
||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
||||
| `server/utils/sanitizeError.js` | 60% | — |
|
||||
| `server/utils/config.js` | 50% | 55% |
|
||||
| Metric | Threshold |
|
||||
|---|---|
|
||||
| Statements | 55% |
|
||||
| Functions | 55% |
|
||||
| Branches | 40% |
|
||||
| Lines | 55% |
|
||||
|
||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
||||
Notable per-file coverage after the current suite:
|
||||
|
||||
| File | Lines | Branches | Notes |
|
||||
|---|---|---|---|
|
||||
| `server/app.js` | ~92% | ~71% | |
|
||||
| `server/routes/auth.js` | ~88% | ~78% | |
|
||||
| `server/routes/dashboard.js` | ~42% | ~25% | SSE `/stream` endpoint intentionally untested |
|
||||
| `server/routes/emby.js` | 100% | 100% | |
|
||||
| `server/routes/radarr.js` | ~87% | ~77% | |
|
||||
| `server/routes/sonarr.js` | ~89% | ~82% | |
|
||||
| `server/routes/sabnzbd.js` | 100% | 100% | |
|
||||
| `server/routes/webhook.js` | ~85% | ~79% | |
|
||||
| `server/middleware/requireAuth.js` | ~92% | ~81% | |
|
||||
| `server/middleware/verifyCsrf.js` | 100% | 80% | |
|
||||
| `server/utils/sanitizeError.js` | 100% | 75% | |
|
||||
| `server/utils/config.js` | ~70% | ~58% | |
|
||||
|
||||
`poller.js` (background polling engine) and the SSE `/stream` endpoint in `dashboard.js` require time-based behaviour and long-lived HTTP connections; they remain tracked as future test coverage work.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/ui/downloads.js
|
||||
*
|
||||
* Verifies DOM rendering functions for tag badges and client logos.
|
||||
* Uses jsdom to create and assert DOM structure.
|
||||
*/
|
||||
|
||||
import { renderTagBadges } from '../../../client/src/ui/downloads.js';
|
||||
|
||||
describe('renderTagBadges', () => {
|
||||
it('returns empty fragment when showAll is false and no matchedUserTag', () => {
|
||||
const result = renderTagBadges([], false, null);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.childNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty fragment when tagBadges is empty', () => {
|
||||
const result = renderTagBadges([], true, null);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.childNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders single matched badge when matchedUserTag is provided', () => {
|
||||
const result = renderTagBadges([], false, 'user1');
|
||||
expect(result.childNodes.length).toBe(1);
|
||||
const badge = result.childNodes[0];
|
||||
expect(badge.className).toBe('download-user-badge');
|
||||
expect(badge.textContent).toBe('user1');
|
||||
});
|
||||
|
||||
it('renders unmatched badges when showAll is true', () => {
|
||||
const tagBadges = [{ label: 'tag1', matchedUser: null }];
|
||||
const result = renderTagBadges(tagBadges, true, null);
|
||||
expect(result.childNodes.length).toBe(1);
|
||||
const badge = result.childNodes[0];
|
||||
expect(badge.className).toBe('download-user-badge unmatched');
|
||||
expect(badge.textContent).toBe('tag1');
|
||||
});
|
||||
|
||||
it('renders matched badges when showAll is true', () => {
|
||||
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
|
||||
const result = renderTagBadges(tagBadges, true, null);
|
||||
expect(result.childNodes.length).toBe(1);
|
||||
const badge = result.childNodes[0];
|
||||
expect(badge.className).toBe('download-user-badge');
|
||||
expect(badge.textContent).toBe('user1');
|
||||
});
|
||||
|
||||
it('renders multiple badges in correct order (unmatched first)', () => {
|
||||
const tagBadges = [
|
||||
{ label: 'tag1', matchedUser: 'user1' },
|
||||
{ label: 'tag2', matchedUser: null }
|
||||
];
|
||||
const result = renderTagBadges(tagBadges, true, null);
|
||||
expect(result.childNodes.length).toBe(2);
|
||||
expect(result.childNodes[0].textContent).toBe('tag2');
|
||||
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
|
||||
expect(result.childNodes[1].textContent).toBe('user1');
|
||||
expect(result.childNodes[1].className).toBe('download-user-badge');
|
||||
});
|
||||
|
||||
it('handles mixed matched and unmatched badges', () => {
|
||||
const tagBadges = [
|
||||
{ label: 'tag1', matchedUser: null },
|
||||
{ label: 'tag2', matchedUser: 'user2' },
|
||||
{ label: 'tag3', matchedUser: null }
|
||||
];
|
||||
const result = renderTagBadges(tagBadges, true, null);
|
||||
expect(result.childNodes.length).toBe(3);
|
||||
// Unmatched badges come first
|
||||
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
|
||||
expect(result.childNodes[0].textContent).toBe('tag1');
|
||||
expect(result.childNodes[1].className).toBe('download-user-badge unmatched');
|
||||
expect(result.childNodes[1].textContent).toBe('tag3');
|
||||
// Matched badges come after
|
||||
expect(result.childNodes[2].className).toBe('download-user-badge');
|
||||
expect(result.childNodes[2].textContent).toBe('user2');
|
||||
});
|
||||
|
||||
it('prefers matchedUserTag over tagBadges when showAll is false', () => {
|
||||
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
|
||||
const result = renderTagBadges(tagBadges, false, 'override');
|
||||
expect(result.childNodes.length).toBe(1);
|
||||
expect(result.childNodes[0].textContent).toBe('override');
|
||||
});
|
||||
|
||||
it('handles null tagBadges gracefully', () => {
|
||||
const result = renderTagBadges(null, true, null);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.childNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handles undefined tagBadges gracefully', () => {
|
||||
const result = renderTagBadges(undefined, true, null);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.childNodes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/utils/format.js
|
||||
*
|
||||
* Verifies formatting utilities for sizes, speeds, dates, and HTML escaping.
|
||||
* These are pure functions that handle edge cases like null, zero, and large numbers.
|
||||
*/
|
||||
|
||||
import { formatSize, formatSpeed, formatDate, formatTimeAgo, escapeHtml } from '../../../client/src/utils/format.js';
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('returns N/A for null/undefined', () => {
|
||||
expect(formatSize(null)).toBe('N/A');
|
||||
expect(formatSize(undefined)).toBe('N/A');
|
||||
});
|
||||
|
||||
it('returns string as-is when already formatted', () => {
|
||||
expect(formatSize('21.5 GB')).toBe('21.5 GB');
|
||||
});
|
||||
|
||||
it('formats bytes correctly', () => {
|
||||
expect(formatSize(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes correctly', () => {
|
||||
expect(formatSize(1024)).toBe('1 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes correctly', () => {
|
||||
expect(formatSize(1024 * 1024)).toBe('1 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes correctly', () => {
|
||||
expect(formatSize(1024 * 1024 * 1024)).toBe('1 GB');
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(formatSize(0)).toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('returns 0 B/s for zero', () => {
|
||||
expect(formatSpeed(0)).toBe('0 B/s');
|
||||
});
|
||||
|
||||
it('returns 0 B/s for null/undefined', () => {
|
||||
expect(formatSpeed(null)).toBe('0 B/s');
|
||||
expect(formatSpeed(undefined)).toBe('0 B/s');
|
||||
});
|
||||
|
||||
it('formats bytes per second correctly', () => {
|
||||
expect(formatSpeed(512)).toBe('512.00 B/s');
|
||||
});
|
||||
|
||||
it('formats kilobytes per second correctly', () => {
|
||||
expect(formatSpeed(1024)).toBe('1.00 KB/s');
|
||||
});
|
||||
|
||||
it('formats megabytes per second correctly', () => {
|
||||
expect(formatSpeed(1024 * 1024)).toBe('1.00 MB/s');
|
||||
});
|
||||
|
||||
it('handles large numbers', () => {
|
||||
expect(formatSpeed(1024 * 1024 * 1024)).toBe('1.00 GB/s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('returns N/A for null/undefined', () => {
|
||||
expect(formatDate(null)).toBe('N/A');
|
||||
expect(formatDate(undefined)).toBe('N/A');
|
||||
});
|
||||
|
||||
it('formats valid date string', () => {
|
||||
const dateStr = '2024-01-15T10:30:00Z';
|
||||
const result = formatDate(dateStr);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).not.toBe('N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeAgo', () => {
|
||||
it('returns Never for null/undefined', () => {
|
||||
expect(formatTimeAgo(null)).toBe('Never');
|
||||
expect(formatTimeAgo(undefined)).toBe('Never');
|
||||
});
|
||||
|
||||
it('returns seconds ago for recent timestamps', () => {
|
||||
const now = Date.now();
|
||||
expect(formatTimeAgo(now - 30000)).toBe('30s ago');
|
||||
});
|
||||
|
||||
it('returns minutes ago for older timestamps', () => {
|
||||
const now = Date.now();
|
||||
expect(formatTimeAgo(now - 60000 * 5)).toBe('5m ago');
|
||||
});
|
||||
|
||||
it('returns hours ago for hours-old timestamps', () => {
|
||||
const now = Date.now();
|
||||
expect(formatTimeAgo(now - 60000 * 60 * 3)).toBe('3h ago');
|
||||
});
|
||||
|
||||
it('returns days ago for day-old timestamps', () => {
|
||||
const now = Date.now();
|
||||
expect(formatTimeAgo(now - 60000 * 60 * 24 * 2)).toBe('2d ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes HTML special characters', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('escapes quotes', () => {
|
||||
expect(escapeHtml('"test"')).toBe('"test"');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles normal text without special chars', () => {
|
||||
expect(escapeHtml('normal text')).toBe('normal text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,899 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for server/routes/sonarr.js, server/routes/radarr.js,
|
||||
* and server/routes/sabnzbd.js.
|
||||
*
|
||||
* Covers:
|
||||
* Sonarr: queue, history, series, series/:id, notifications CRUD,
|
||||
* notifications/test, notifications/schema, sofarr-webhook (create + update)
|
||||
* Radarr: same set, movies instead of series
|
||||
* SABnzbd: queue, history
|
||||
*
|
||||
* All routes require auth; state-changing requests (POST/PUT/DELETE) must also
|
||||
* carry the CSRF token issued by GET /api/auth/csrf (verifyCsrf middleware).
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
const SONARR_BASE = 'https://sonarr.test';
|
||||
const RADARR_BASE = 'https://radarr.test';
|
||||
const SABNZBD_BASE = 'https://sabnzbd.test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||
|
||||
const SONARR_QUEUE = { records: [{ id: 1, title: 'Show.S01E01', seriesId: 10 }] };
|
||||
const SONARR_HISTORY = { records: [{ id: 100, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01' }] };
|
||||
const SONARR_SERIES_LIST = [{ id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }];
|
||||
const SONARR_SERIES_ITEM = { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] };
|
||||
const SONARR_NOTIFICATIONS = [{ id: 5, name: 'Plex', implementation: 'Plex' }];
|
||||
const SONARR_NOTIF_ITEM = { id: 5, name: 'Plex', implementation: 'Plex', fields: [] };
|
||||
|
||||
const RADARR_QUEUE = { records: [{ id: 2, title: 'Movie.2024.1080p', movieId: 20 }] };
|
||||
const RADARR_HISTORY = { records: [{ id: 200, eventType: 'downloadFolderImported', sourceTitle: 'Movie.2024.1080p' }] };
|
||||
const RADARR_MOVIES_LIST = [{ id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }];
|
||||
const RADARR_MOVIE_ITEM = { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] };
|
||||
const RADARR_NOTIFICATIONS = [{ id: 7, name: 'Plex', implementation: 'Plex' }];
|
||||
const RADARR_NOTIF_ITEM = { id: 7, name: 'Plex', implementation: 'Plex', fields: [] };
|
||||
|
||||
const SAB_QUEUE_RESP = { queue: { status: 'Downloading', slots: [{ filename: 'Show.S01E01', percentage: '50' }] } };
|
||||
const SAB_HISTORY_RESP = { history: { slots: [{ name: 'Show.S01E01.Done', status: 'Completed' }] } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function interceptLogin() {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
|
||||
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
|
||||
}
|
||||
|
||||
async function loginAs(app) {
|
||||
interceptLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'pw' });
|
||||
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
|
||||
}
|
||||
|
||||
async function getSessionWithCsrf(app) {
|
||||
const { cookies, csrf } = await loginAs(app);
|
||||
// Obtain a fresh csrf cookie as well (login already sets one, but keep consistent)
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
return { cookies, csrf, csrfCookie };
|
||||
}
|
||||
|
||||
// Build the Cookie header for state-changing requests: session + csrf cookies
|
||||
function joinCookies(sessionCookies, csrfCookie) {
|
||||
const all = Array.isArray(sessionCookies) ? [...sessionCookies] : [sessionCookies];
|
||||
if (csrfCookie && !all.includes(csrfCookie)) all.push(csrfCookie);
|
||||
return all.join('; ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SONARR_URL = SONARR_BASE;
|
||||
process.env.SONARR_API_KEY = 'sk';
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
||||
process.env.RADARR_URL = RADARR_BASE;
|
||||
process.env.RADARR_API_KEY = 'rk';
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
||||
process.env.SABNZBD_URL = SABNZBD_BASE;
|
||||
process.env.SABNZBD_API_KEY = 'sabkey';
|
||||
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
||||
process.env.SOFARR_WEBHOOK_SECRET = 'webhook-secret-abc';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.SONARR_URL;
|
||||
delete process.env.SONARR_API_KEY;
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
delete process.env.RADARR_API_KEY;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.SABNZBD_URL;
|
||||
delete process.env.SABNZBD_API_KEY;
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// SONARR ROUTES
|
||||
// ===========================================================================
|
||||
|
||||
describe('Sonarr routes', () => {
|
||||
describe('GET /api/sonarr/queue', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/sonarr/queue');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies Sonarr queue', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/queue').reply(200, SONARR_QUEUE);
|
||||
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.records).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/queue/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/history', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/sonarr/history');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies Sonarr history with default pageSize', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/history').query(true).reply(200, SONARR_HISTORY);
|
||||
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.records).toBeDefined();
|
||||
});
|
||||
|
||||
it('passes through custom pageSize', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/history').query({ pageSize: '100' }).reply(200, SONARR_HISTORY);
|
||||
const res = await request(app).get('/api/sonarr/history?pageSize=100').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/series', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/sonarr/series');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies series list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/series').reply(200, SONARR_SERIES_LIST);
|
||||
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/series').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/series/:id', () => {
|
||||
it('proxies individual series', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/series/10').reply(200, SONARR_SERIES_ITEM);
|
||||
const res = await request(app).get('/api/sonarr/series/10').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('My Show');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/series/999').replyWithError('not found');
|
||||
const res = await request(app).get('/api/sonarr/series/999').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/notifications', () => {
|
||||
it('returns 503 when no Sonarr instance configured', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// Temporarily clear instances
|
||||
const saved = process.env.SONARR_INSTANCES;
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.SONARR_URL;
|
||||
delete process.env.SONARR_API_KEY;
|
||||
|
||||
interceptLogin();
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||
const cookies = loginRes.headers['set-cookie'];
|
||||
|
||||
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(503);
|
||||
|
||||
process.env.SONARR_INSTANCES = saved;
|
||||
process.env.SONARR_URL = SONARR_BASE;
|
||||
process.env.SONARR_API_KEY = 'sk';
|
||||
});
|
||||
|
||||
it('proxies notifications list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/notification').reply(200, SONARR_NOTIFICATIONS);
|
||||
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/notifications/:id', () => {
|
||||
it('proxies a single notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/notification/5').reply(200, SONARR_NOTIF_ITEM);
|
||||
const res = await request(app).get('/api/sonarr/notifications/5').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Plex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sonarr/notifications', () => {
|
||||
it('returns 403 (CSRF missing) without auth', async () => {
|
||||
// verifyCsrf fires before requireAuth on POST routes — no CSRF → 403
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).post('/api/sonarr/notifications').send({});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('creates a notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 99, name: 'New' });
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ name: 'New' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('New');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).post('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ name: 'New' });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/sonarr/notifications/:id', () => {
|
||||
it('updates a notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).put('/api/v3/notification/5').reply(200, { id: 5, name: 'Updated' });
|
||||
const res = await request(app)
|
||||
.put('/api/sonarr/notifications/5')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 5, name: 'Updated' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).put('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.put('/api/sonarr/notifications/5')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 5 });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/sonarr/notifications/:id', () => {
|
||||
it('deletes a notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).delete('/api/v3/notification/5').reply(200, {});
|
||||
const res = await request(app)
|
||||
.delete('/api/sonarr/notifications/5')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).delete('/api/v3/notification/5').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.delete('/api/sonarr/notifications/5')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sonarr/notifications/test', () => {
|
||||
it('returns 503 when no Sonarr instance configured', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const saved = process.env.SONARR_INSTANCES;
|
||||
const savedUrl = process.env.SONARR_URL;
|
||||
const savedKey = process.env.SONARR_API_KEY;
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.SONARR_URL;
|
||||
delete process.env.SONARR_API_KEY;
|
||||
|
||||
interceptLogin();
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||
const cookies = loginRes.headers['set-cookie'];
|
||||
const csrf = loginRes.body.csrfToken;
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/test')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(503);
|
||||
|
||||
process.env.SONARR_INSTANCES = saved;
|
||||
process.env.SONARR_URL = savedUrl;
|
||||
process.env.SONARR_API_KEY = savedKey;
|
||||
});
|
||||
|
||||
it('tests a notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/test')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 5 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 when test fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(SONARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/test')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 5 });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sonarr/notifications/schema', () => {
|
||||
it('proxies the schema', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SONARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
||||
const res = await request(app).get('/api/sonarr/notifications/schema').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sonarr/notifications/sofarr-webhook', () => {
|
||||
it('returns 400 when SOFARR_BASE_URL is not configured', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const saved = process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
|
||||
interceptLogin();
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||
const cookies = loginRes.headers['set-cookie'];
|
||||
const csrf = loginRes.body.csrfToken;
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/SOFARR_BASE_URL/);
|
||||
|
||||
process.env.SOFARR_BASE_URL = saved;
|
||||
});
|
||||
|
||||
it('creates a new webhook notification when none exists', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
|
||||
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 10, name: 'Sofarr' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Sofarr');
|
||||
});
|
||||
|
||||
it('updates an existing Sofarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/notification')
|
||||
.reply(200, [{ id: 10, name: 'Sofarr', implementation: 'Webhook' }]);
|
||||
nock(SONARR_BASE)
|
||||
.put('/api/v3/notification/10')
|
||||
.reply(200, { id: 10, name: 'Sofarr' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Sofarr');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
|
||||
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/sonarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// RADARR ROUTES
|
||||
// ===========================================================================
|
||||
|
||||
describe('Radarr routes', () => {
|
||||
describe('GET /api/radarr/queue', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/radarr/queue');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies Radarr queue', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/queue').reply(200, RADARR_QUEUE);
|
||||
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.records).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/history', () => {
|
||||
it('proxies Radarr history', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/history').query(true).reply(200, RADARR_HISTORY);
|
||||
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/radarr/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/movies', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/radarr/movies');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies movies list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/movie').reply(200, RADARR_MOVIES_LIST);
|
||||
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/movie').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/movies/:id', () => {
|
||||
it('proxies a single movie', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/movie/20').reply(200, RADARR_MOVIE_ITEM);
|
||||
const res = await request(app).get('/api/radarr/movies/20').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('My Movie');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/movie/999').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/radarr/movies/999').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/notifications', () => {
|
||||
it('returns 503 when no Radarr instance configured', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const saved = process.env.RADARR_INSTANCES;
|
||||
const savedUrl = process.env.RADARR_URL;
|
||||
const savedKey = process.env.RADARR_API_KEY;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
delete process.env.RADARR_URL;
|
||||
delete process.env.RADARR_API_KEY;
|
||||
|
||||
interceptLogin();
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||
const cookies = loginRes.headers['set-cookie'];
|
||||
|
||||
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(503);
|
||||
|
||||
process.env.RADARR_INSTANCES = saved;
|
||||
process.env.RADARR_URL = savedUrl;
|
||||
process.env.RADARR_API_KEY = savedKey;
|
||||
});
|
||||
|
||||
it('proxies notifications list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').reply(200, RADARR_NOTIFICATIONS);
|
||||
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/radarr/notifications', () => {
|
||||
it('creates a Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 88, name: 'New' });
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ name: 'New' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('New');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/radarr/notifications/:id', () => {
|
||||
it('updates a Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).put('/api/v3/notification/7').reply(200, { id: 7, name: 'Updated' });
|
||||
const res = await request(app)
|
||||
.put('/api/radarr/notifications/7')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 7, name: 'Updated' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).put('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.put('/api/radarr/notifications/7')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 7 });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/radarr/notifications/:id', () => {
|
||||
it('deletes a Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).delete('/api/v3/notification/7').reply(200, {});
|
||||
const res = await request(app)
|
||||
.delete('/api/radarr/notifications/7')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).delete('/api/v3/notification/7').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.delete('/api/radarr/notifications/7')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/notifications/:id', () => {
|
||||
it('proxies a single Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/notification/7').reply(200, RADARR_NOTIF_ITEM);
|
||||
const res = await request(app).get('/api/radarr/notifications/7').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Plex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/radarr/notifications/test', () => {
|
||||
it('tests a Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).post('/api/v3/notification/test').reply(200, {});
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/test')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 7 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 when test fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/test')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ id: 7 });
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/radarr/notifications/schema', () => {
|
||||
it('proxies the Radarr notification schema', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(RADARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]);
|
||||
const res = await request(app).get('/api/radarr/notifications/schema').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/radarr/notifications/sofarr-webhook', () => {
|
||||
it('creates a new Radarr webhook when none exists', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
|
||||
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 20, name: 'Sofarr' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Sofarr');
|
||||
});
|
||||
|
||||
it('updates an existing Sofarr Radarr notification', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/notification')
|
||||
.reply(200, [{ id: 20, name: 'Sofarr', implementation: 'Webhook' }]);
|
||||
nock(RADARR_BASE)
|
||||
.put('/api/v3/notification/20')
|
||||
.reply(200, { id: 20, name: 'Sofarr' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 400 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const saved = process.env.SOFARR_WEBHOOK_SECRET;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
|
||||
interceptLogin();
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' });
|
||||
const cookies = loginRes.headers['set-cookie'];
|
||||
const csrf = loginRes.body.csrfToken;
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/SOFARR_WEBHOOK_SECRET/);
|
||||
|
||||
process.env.SOFARR_WEBHOOK_SECRET = saved;
|
||||
});
|
||||
|
||||
it('returns 500 on upstream failure', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED');
|
||||
const res = await request(app)
|
||||
.post('/api/radarr/notifications/sofarr-webhook')
|
||||
.set('Cookie', joinCookies(cookies, csrfCookie))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({});
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// SABNZBD ROUTES
|
||||
// ===========================================================================
|
||||
|
||||
describe('SABnzbd routes', () => {
|
||||
describe('GET /api/sabnzbd/queue', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/sabnzbd/queue');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies SABnzbd queue', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SABNZBD_BASE)
|
||||
.get('/api')
|
||||
.query({ mode: 'queue', apikey: 'sabkey', output: 'json' })
|
||||
.reply(200, SAB_QUEUE_RESP);
|
||||
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.queue).toBeDefined();
|
||||
expect(res.body.queue.status).toBe('Downloading');
|
||||
});
|
||||
|
||||
it('returns 500 when SABnzbd is unreachable', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SABNZBD_BASE)
|
||||
.get('/api')
|
||||
.query(true)
|
||||
.replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/queue/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sabnzbd/history', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/sabnzbd/history');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies SABnzbd history with default limit', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SABNZBD_BASE)
|
||||
.get('/api')
|
||||
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '50' })
|
||||
.reply(200, SAB_HISTORY_RESP);
|
||||
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toBeDefined();
|
||||
});
|
||||
|
||||
it('passes through custom limit', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SABNZBD_BASE)
|
||||
.get('/api')
|
||||
.query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '100' })
|
||||
.reply(200, SAB_HISTORY_RESP);
|
||||
const res = await request(app).get('/api/sabnzbd/history?limit=100').set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns 500 when SABnzbd is unreachable', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock(SABNZBD_BASE)
|
||||
.get('/api')
|
||||
.query(true)
|
||||
.replyWithError('ECONNREFUSED');
|
||||
const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/history/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,837 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for server/routes/dashboard.js
|
||||
*
|
||||
* Strategy:
|
||||
* - createApp({ skipRateLimits: true }) for a real Express instance
|
||||
* - nock intercepts Emby auth so we can obtain a valid session cookie
|
||||
* - cache is seeded directly (same technique as history.test.js) so the
|
||||
* route's cache.get() calls return controlled fixture data without any
|
||||
* real outbound HTTP to SABnzbd / Sonarr / Radarr / qBittorrent
|
||||
* - nock is used for outbound axios calls made by the routes themselves
|
||||
* (cover-art proxy, blocklist-search, status webhook-check)
|
||||
*
|
||||
* Covers:
|
||||
* GET /api/dashboard/user-downloads — auth guard, SAB+Sonarr, SAB+Radarr,
|
||||
* qBittorrent, showAll (admin), empty cache, on-demand poll trigger,
|
||||
* paused queue speed, error propagation
|
||||
* GET /api/dashboard/status — admin-only guard, shape check
|
||||
* GET /api/dashboard/webhook-metrics — any authenticated user
|
||||
* GET /api/dashboard/cover-art — missing url, non-http scheme, proxy, non-image
|
||||
* POST /api/dashboard/blocklist-search — admin guard, validation, sonarr+radarr paths
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createRequire } from 'module';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
const SONARR_BASE = 'https://sonarr.test';
|
||||
const RADARR_BASE = 'https://radarr.test';
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire in a test run
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||
const EMBY_ADMIN_AUTH = { AccessToken: 'tok-admin', User: { Id: 'uid2', Name: 'admin' } };
|
||||
const EMBY_ADMIN_USER = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
|
||||
|
||||
// Tag id 1 → 'alice', id 2 → 'admin'
|
||||
const SONARR_TAGS = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
|
||||
const RADARR_TAGS = [{ id: 10, label: 'alice' }, { id: 11, label: 'admin' }];
|
||||
|
||||
const SERIES = {
|
||||
id: 42,
|
||||
title: 'My Show',
|
||||
titleSlug: 'my-show',
|
||||
tags: [1],
|
||||
path: '/tv/my-show',
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg' }],
|
||||
_instanceUrl: SONARR_BASE
|
||||
};
|
||||
|
||||
const ADMIN_SERIES = {
|
||||
id: 43,
|
||||
title: 'Admin Show',
|
||||
titleSlug: 'admin-show',
|
||||
tags: [2],
|
||||
path: '/tv/admin-show',
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/admin-poster.jpg' }],
|
||||
_instanceUrl: SONARR_BASE
|
||||
};
|
||||
|
||||
const ADMIN_SAB_SLOT = {
|
||||
filename: 'Admin.Show.S01E01.720p',
|
||||
nzbname: 'Admin.Show.S01E01.720p',
|
||||
nzo_id: 'SABnzbd_nzo_admin001',
|
||||
percentage: '40',
|
||||
mb: '500',
|
||||
mbmissing: '300',
|
||||
size: '500 MB',
|
||||
status: 'Downloading',
|
||||
storage: '/downloads/Admin.Show.S01E01.720p',
|
||||
timeleft: '0:08:00'
|
||||
};
|
||||
|
||||
const ADMIN_SONARR_QUEUE_RECORD = {
|
||||
id: 1002,
|
||||
title: 'Admin.Show.S01E01.720p',
|
||||
seriesId: 43,
|
||||
series: ADMIN_SERIES,
|
||||
episodeId: 502,
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'ok',
|
||||
_instanceUrl: SONARR_BASE,
|
||||
_instanceKey: 'sonarr-api-key'
|
||||
};
|
||||
|
||||
const MOVIE = {
|
||||
id: 99,
|
||||
title: 'My Movie',
|
||||
titleSlug: 'my-movie-2024',
|
||||
tags: [10],
|
||||
path: '/movies/my-movie',
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/movie-poster.jpg' }],
|
||||
_instanceUrl: RADARR_BASE
|
||||
};
|
||||
|
||||
const SAB_QUEUE_SLOT = {
|
||||
filename: 'My.Show.S01E01.720p',
|
||||
nzbname: 'My.Show.S01E01.720p',
|
||||
nzo_id: 'SABnzbd_nzo_abc123',
|
||||
percentage: '55',
|
||||
mb: '700',
|
||||
mbmissing: '315',
|
||||
size: '700 MB',
|
||||
status: 'Downloading',
|
||||
storage: '/downloads/My.Show.S01E01.720p',
|
||||
timeleft: '0:10:00'
|
||||
};
|
||||
|
||||
const SAB_MOVIE_SLOT = {
|
||||
filename: 'My.Movie.2024.1080p',
|
||||
nzbname: 'My.Movie.2024.1080p',
|
||||
nzo_id: 'SABnzbd_nzo_xyz999',
|
||||
percentage: '80',
|
||||
mb: '4000',
|
||||
mbmissing: '800',
|
||||
size: '4 GB',
|
||||
status: 'Downloading',
|
||||
timeleft: '0:05:00'
|
||||
};
|
||||
|
||||
const SONARR_QUEUE_RECORD = {
|
||||
id: 1001,
|
||||
title: 'My.Show.S01E01.720p',
|
||||
seriesId: 42,
|
||||
series: SERIES,
|
||||
episodeId: 501,
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'ok',
|
||||
_instanceUrl: SONARR_BASE,
|
||||
_instanceKey: 'sonarr-api-key'
|
||||
};
|
||||
|
||||
const RADARR_QUEUE_RECORD = {
|
||||
id: 2001,
|
||||
title: 'My.Movie.2024.1080p',
|
||||
movieId: 99,
|
||||
movie: MOVIE,
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'ok',
|
||||
_instanceUrl: RADARR_BASE,
|
||||
_instanceKey: 'radarr-api-key'
|
||||
};
|
||||
|
||||
const QBIT_TORRENT = {
|
||||
hash: 'abc123def456',
|
||||
name: 'My.Show.S01E01.720p',
|
||||
state: 'downloading',
|
||||
progress: 0.55,
|
||||
size: 734003200,
|
||||
downloaded: 403701760,
|
||||
uploadSpeed: 0,
|
||||
downloadSpeed: 1024000,
|
||||
eta: 300,
|
||||
savePath: '/downloads/torrents/',
|
||||
addedOn: Date.now() / 1000 - 7200
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache seeding helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function seedEmptyCache() {
|
||||
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll: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', [], CACHE_TTL);
|
||||
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||
}
|
||||
|
||||
function seedSabSonarrCache() {
|
||||
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [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);
|
||||
}
|
||||
|
||||
function seedSabRadarrCache() {
|
||||
cache.set('poll:sab-queue', { slots: [SAB_MOVIE_SLOT], status: 'Downloading', speed: '5 MB/s' }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-tags', [], CACHE_TTL);
|
||||
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, 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);
|
||||
}
|
||||
|
||||
function seedQbittorrentSonarrCache() {
|
||||
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [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', [QBIT_TORRENT], CACHE_TTL);
|
||||
}
|
||||
|
||||
function invalidatePollCache() {
|
||||
const keys = [
|
||||
'poll:sab-queue', 'poll:sab-history',
|
||||
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
||||
'poll:qbittorrent'
|
||||
];
|
||||
for (const k of keys) cache.invalidate(k);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function interceptEmbyLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
|
||||
}
|
||||
|
||||
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||
interceptEmbyLogin(userBody, authBody);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: userBody.Name, password: 'pw' });
|
||||
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
|
||||
}
|
||||
|
||||
// CSRF token must be sent with state-changing (POST) requests that go through
|
||||
// the verifyCsrf middleware. GET requests under /api/dashboard do not need it.
|
||||
async function csrfHeaders(app) {
|
||||
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||
const token = csrfRes.body.csrfToken;
|
||||
const cookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||
return { token, cookie };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
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.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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
invalidatePollCache();
|
||||
cache.invalidate('emby:users');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/dashboard/user-downloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/dashboard/user-downloads', () => {
|
||||
describe('authentication', () => {
|
||||
it('returns 401 when not logged in', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/dashboard/user-downloads');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty cache', () => {
|
||||
it('returns empty downloads array for authenticated user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedEmptyCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBe('alice');
|
||||
expect(res.body.isAdmin).toBe(false);
|
||||
expect(res.body.downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SABnzbd + Sonarr queue matching', () => {
|
||||
it('returns a series download when SAB slot title matches Sonarr queue record', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedSabSonarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const downloads = res.body.downloads;
|
||||
expect(downloads.length).toBeGreaterThanOrEqual(1);
|
||||
const dl = downloads[0];
|
||||
expect(dl.type).toBe('series');
|
||||
expect(dl.seriesName).toBe('My Show');
|
||||
expect(dl.coverArt).toBe('https://img.test/poster.jpg');
|
||||
});
|
||||
|
||||
it('includes admin-only fields when user is admin', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
// Seed a SAB slot + Sonarr record tagged for 'admin' so the admin user gets a result
|
||||
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);
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.arrQueueId).toBe(1002);
|
||||
expect(dl.arrType).toBe('sonarr');
|
||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||
expect(dl.downloadPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include admin-only fields for non-admin user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedSabSonarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.arrQueueId).toBeUndefined();
|
||||
expect(dl.arrType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not return downloads tagged for a different user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// Login as 'bob' — series is tagged 'alice'
|
||||
interceptEmbyLogin({ Id: 'uid-bob', Name: 'bob', Policy: { IsAdministrator: false } }, { AccessToken: 'tok-bob', User: { Id: 'uid-bob', Name: 'bob' } });
|
||||
const res1 = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'bob', password: 'pw' });
|
||||
const bobCookies = res1.headers['set-cookie'];
|
||||
|
||||
seedSabSonarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', bobCookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.downloads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SABnzbd + Radarr queue matching', () => {
|
||||
it('returns a movie download when SAB slot title matches Radarr queue record', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedSabRadarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'movie');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.movieName).toBe('My Movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qBittorrent + Sonarr queue matching', () => {
|
||||
it('returns a series download from a qBittorrent torrent', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedQbittorrentSonarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.seriesName).toBe('My Show');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paused queue', () => {
|
||||
it('reports Paused status when SAB queue is paused', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Paused', speed: '0' }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [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);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
if (dl) {
|
||||
expect(dl.status).toBe('Paused');
|
||||
expect(dl.speed).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAll (admin)', () => {
|
||||
it('returns downloads for all tagged users when showAll=true', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
seedSabSonarrCache();
|
||||
|
||||
// Stub Emby users list used by getEmbyUsers()
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users')
|
||||
.reply(200, [{ Name: 'alice' }, { Name: 'bob' }]);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads?showAll=true')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.isAdmin).toBe(true);
|
||||
// tagBadges should be present on results when showAll is active
|
||||
const dl = res.body.downloads.find(d => d.allTags && d.allTags.length > 0);
|
||||
if (dl) {
|
||||
expect(Array.isArray(dl.tagBadges)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('non-admin cannot use showAll — still filtered to their own tags', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedSabSonarrCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads?showAll=true')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
// Non-admin: showAll has no effect, tagBadges must be absent
|
||||
const dl = res.body.downloads[0];
|
||||
if (dl) expect(dl.tagBadges).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshRate tracking', () => {
|
||||
it('accepts refreshRate query parameter without error', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
seedEmptyCache();
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads?refreshRate=10000')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SABnzbd history matching', () => {
|
||||
it('returns a series download matched from SAB history + Sonarr history', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const historySlot = {
|
||||
name: 'My.Show.S01E02.720p',
|
||||
status: 'Completed',
|
||||
size: '700 MB',
|
||||
completed_time: Math.floor(Date.now() / 1000) - 3600
|
||||
};
|
||||
const sonarrHistoryRecord = {
|
||||
id: 9001,
|
||||
sourceTitle: 'My.Show.S01E02.720p',
|
||||
seriesId: 42,
|
||||
series: { ...SERIES },
|
||||
episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' }
|
||||
};
|
||||
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [historySlot] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-history', { records: [sonarrHistoryRecord] }, 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);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.seriesName).toBe('My Show');
|
||||
});
|
||||
});
|
||||
|
||||
describe('import issues', () => {
|
||||
it('includes importIssues when Sonarr record has warning status', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const problemRecord = {
|
||||
...SONARR_QUEUE_RECORD,
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'warning',
|
||||
statusMessages: [{ messages: ['No suitable video file found'] }]
|
||||
};
|
||||
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||
cache.set('poll:sonarr-queue', { records: [problemRecord] }, 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);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/user-downloads')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const dl = res.body.downloads.find(d => d.importIssues);
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.importIssues).toContain('No suitable video file found');
|
||||
expect(dl.canBlocklist).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/status', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin users', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
|
||||
// Status route fetches Sonarr/Radarr notifications — intercept them
|
||||
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/status')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('returns server/cache/polling/webhook stats for admin', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
|
||||
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/status')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.server).toBeDefined();
|
||||
expect(typeof res.body.server.uptimeSeconds).toBe('number');
|
||||
expect(typeof res.body.server.nodeVersion).toBe('string');
|
||||
expect(res.body.cache).toBeDefined();
|
||||
expect(res.body.polling).toBeDefined();
|
||||
expect(res.body.webhooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles Sonarr/Radarr notification check failures gracefully', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
|
||||
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
|
||||
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/status')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.webhooks.sonarr).toBeNull();
|
||||
expect(res.body.webhooks.radarr).toBeNull();
|
||||
});
|
||||
|
||||
it('reports webhook configured=true when Sofarr notification exists', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/notification')
|
||||
.reply(200, [{ name: 'Sofarr', implementation: 'Webhook' }]);
|
||||
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/status')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.webhooks.sonarr).toBeDefined();
|
||||
expect(res.body.webhooks.sonarr.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/dashboard/cover-art
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/dashboard/cover-art', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/dashboard/cover-art?url=https://img.test/poster.jpg');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 when url parameter is missing', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/missing url/i);
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid URL', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art?url=not-a-url')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid url/i);
|
||||
});
|
||||
|
||||
it('returns 400 for non-http/https scheme', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art?url=ftp://img.test/poster.jpg')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/http/i);
|
||||
});
|
||||
|
||||
it('returns 400 when remote URL is not an image', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock('https://img.test')
|
||||
.get('/notanimage.html')
|
||||
.reply(200, '<html/>', { 'content-type': 'text/html' });
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art?url=https://img.test/notanimage.html')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not an image/i);
|
||||
});
|
||||
|
||||
it('returns 502 when remote image fetch fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock('https://img.test')
|
||||
.get('/poster.jpg')
|
||||
.replyWithError('connection refused');
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it('proxies an image and sets correct headers', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies } = await loginAs(app);
|
||||
nock('https://img.test')
|
||||
.get('/poster.jpg')
|
||||
.reply(200, Buffer.from('FAKEJPEG'), { 'content-type': 'image/jpeg' });
|
||||
const res = await request(app)
|
||||
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=86400/);
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/dashboard/blocklist-search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('POST /api/dashboard/blocklist-search', () => {
|
||||
async function getAuthHeaders(app, userBody = EMBY_ADMIN_USER, authBody = EMBY_ADMIN_AUTH) {
|
||||
const { cookies, csrf } = await loginAs(app, userBody, authBody);
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
return { cookies, csrfCookie, csrf };
|
||||
}
|
||||
|
||||
it('returns 403 (CSRF missing) when not authenticated', async () => {
|
||||
// verifyCsrf middleware fires before requireAuth for POST routes;
|
||||
// an unauthenticated POST without CSRF headers gets 403, not 401.
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin user', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf } = await loginAs(app);
|
||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('returns 400 when required fields are missing', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/missing/i);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid arrType', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1, arrType: 'invalid', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/sonarr or radarr/i);
|
||||
});
|
||||
|
||||
it('calls Sonarr DELETE+command and returns ok:true', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_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)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
nock(RADARR_BASE)
|
||||
.delete('/api/v3/queue/2001')
|
||||
.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)
|
||||
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query(true)
|
||||
.replyWithError('connection refused');
|
||||
|
||||
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', arrContentId: 501, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for server/routes/emby.js
|
||||
*
|
||||
* All four endpoints are covered:
|
||||
* GET /api/emby/sessions
|
||||
* GET /api/emby/users
|
||||
* GET /api/emby/users/:id
|
||||
* GET /api/emby/session/:sessionId/user
|
||||
*
|
||||
* For each: auth guard (401), happy path, and upstream failure (500).
|
||||
* No CSRF token is needed — all routes are read-only GETs.
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||
|
||||
const EMBY_SESSIONS = [
|
||||
{ Id: 'sess-001', UserId: 'uid1', UserName: 'alice', Client: 'Emby Web', DeviceName: 'Chrome' },
|
||||
{ Id: 'sess-002', UserId: 'uid2', UserName: 'bob', Client: 'Emby iOS', DeviceName: 'iPhone' }
|
||||
];
|
||||
|
||||
const EMBY_USERS_LIST = [
|
||||
{ Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } },
|
||||
{ Id: 'uid2', Name: 'bob', Policy: { IsAdministrator: false } }
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function interceptLogin() {
|
||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH);
|
||||
nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER);
|
||||
}
|
||||
|
||||
async function loginAs(app) {
|
||||
interceptLogin();
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'alice', password: 'pw' });
|
||||
return res.headers['set-cookie'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.EMBY_API_KEY = 'emby-api-key';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.EMBY_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/emby/sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/emby/sessions', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/emby/sessions');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies Emby sessions list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Sessions')
|
||||
.reply(200, EMBY_SESSIONS);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/sessions')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBe(2);
|
||||
expect(res.body[0].Id).toBe('sess-001');
|
||||
});
|
||||
|
||||
it('returns 500 when Emby is unreachable', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Sessions')
|
||||
.replyWithError('ECONNREFUSED');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/sessions')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/sessions/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/emby/users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/emby/users', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/emby/users');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies Emby users list', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users')
|
||||
.reply(200, EMBY_USERS_LIST);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/users')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body[0].Name).toBe('alice');
|
||||
});
|
||||
|
||||
it('returns 500 when Emby is unreachable', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users')
|
||||
.replyWithError('ECONNREFUSED');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/users')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/users/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/emby/users/:id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/emby/users/:id', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/emby/users/uid1');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('proxies individual user details', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/uid1')
|
||||
.reply(200, EMBY_USER);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/users/uid1')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Id).toBe('uid1');
|
||||
expect(res.body.Name).toBe('alice');
|
||||
});
|
||||
|
||||
it('returns 500 when Emby returns an error', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/uid-unknown')
|
||||
.reply(404, { error: 'Not found' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/users/uid-unknown')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/emby/session/:sessionId/user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/emby/session/:sessionId/user', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/emby/session/sess-001/user');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the user associated with a session', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Sessions')
|
||||
.reply(200, EMBY_SESSIONS);
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/uid1')
|
||||
.reply(200, EMBY_USER);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/session/sess-001/user')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.Name).toBe('alice');
|
||||
});
|
||||
|
||||
it('returns 404 when session ID is not found', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Sessions')
|
||||
.reply(200, EMBY_SESSIONS);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/session/sess-nonexistent/user')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/session not found/i);
|
||||
});
|
||||
|
||||
it('returns 500 when Emby sessions fetch fails', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const cookies = await loginAs(app);
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Sessions')
|
||||
.replyWithError('ECONNREFUSED');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/emby/session/sess-001/user')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/session/i);
|
||||
});
|
||||
});
|
||||
@@ -398,4 +398,187 @@ describe('GET /api/history/recent', () => {
|
||||
expect(Array.isArray(res.body.history)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staged loading - race conditions', () => {
|
||||
it('handles concurrent requests without data loss', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// Set up 150 records with unique episodeIds to test staged loading
|
||||
const sonarrRecords = Array.from({ length: 150 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
episodeId: i + 1, // Unique episodeId for each record
|
||||
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
setHistory(sonarrRecords, []);
|
||||
const { cookies } = await loginAs(app);
|
||||
|
||||
// Make concurrent requests
|
||||
const [res1, res2, res3] = await Promise.all([
|
||||
request(app).get('/api/history/recent').set('Cookie', cookies),
|
||||
request(app).get('/api/history/recent').set('Cookie', cookies),
|
||||
request(app).get('/api/history/recent').set('Cookie', cookies)
|
||||
]);
|
||||
|
||||
// All requests should succeed
|
||||
expect(res1.status).toBe(200);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res3.status).toBe(200);
|
||||
|
||||
// All should return the same data (cache hit)
|
||||
expect(res1.body.history).toEqual(res2.body.history);
|
||||
expect(res2.body.history).toEqual(res3.body.history);
|
||||
|
||||
// Verify no duplicate episodeIds
|
||||
const episodeIds = res1.body.history.map(h => h.title);
|
||||
const uniqueEpisodeIds = new Set(episodeIds);
|
||||
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
||||
});
|
||||
|
||||
it('maintains cache consistency during background fetch', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// Start with 100 records with unique episodeIds
|
||||
const initialRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
episodeId: i + 1,
|
||||
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
setHistory(initialRecords, []);
|
||||
const { cookies } = await loginAs(app);
|
||||
|
||||
// First request populates cache
|
||||
const res1 = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res1.status).toBe(200);
|
||||
expect(res1.body.history).toHaveLength(100);
|
||||
|
||||
// Add more records to simulate background fetch
|
||||
const additionalRecords = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i + 101,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 101}`,
|
||||
date: new Date(Date.now() - (i + 100) * 60000).toISOString(),
|
||||
episodeId: i + 101,
|
||||
episode: { seasonNumber: 1, episodeNumber: i + 101, title: `Episode ${i + 101}`, hasFile: true },
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
setHistory([...initialRecords, ...additionalRecords], []);
|
||||
|
||||
// Invalidate cache to simulate background fetch completion
|
||||
cache.invalidate('history:sonarr');
|
||||
cache.set('history:sonarr', [...initialRecords, ...additionalRecords].map(r => ({
|
||||
...r,
|
||||
_instanceName: 'Main Sonarr',
|
||||
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
||||
})), CACHE_TTL);
|
||||
|
||||
// Second request should get updated data
|
||||
const res2 = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.history).toHaveLength(150);
|
||||
|
||||
// Verify no duplicates
|
||||
const episodeIds = res2.body.history.map(h => h.title);
|
||||
const uniqueEpisodeIds = new Set(episodeIds);
|
||||
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
||||
});
|
||||
|
||||
it('handles duplicate records gracefully', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// Create records with duplicate IDs (simulating race condition)
|
||||
const records = [
|
||||
{
|
||||
id: 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01',
|
||||
date: new Date().toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E02',
|
||||
date: new Date(Date.now() - 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
},
|
||||
{
|
||||
id: 1, // Duplicate ID
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: 'Show.S01E01',
|
||||
date: new Date(Date.now() - 120000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
}
|
||||
];
|
||||
setHistory(records, []);
|
||||
const { cookies } = await loginAs(app);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The deduplication in history.js should handle this
|
||||
// We should get 2 unique items, not 3
|
||||
const uniqueSeries = new Set(res.body.history.map(h => h.title));
|
||||
expect(uniqueSeries.size).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staged loading - edge cases', () => {
|
||||
it('handles empty history', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single record', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles exactly 100 records (batch boundary)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const records = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${(i % 10) + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
setHistory(records, []);
|
||||
const { cookies } = await loginAs(app);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.history).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Integration tests for webhook endpoints:
|
||||
* POST /api/webhook/sonarr
|
||||
* POST /api/webhook/radarr
|
||||
*
|
||||
* Uses supertest against createApp() (no real server).
|
||||
* processWebhookEvent() makes outbound *arr API calls — those are blocked by
|
||||
* nock so tests remain hermetic (fire-and-forget, not awaited by the handler).
|
||||
*
|
||||
* Covers:
|
||||
* - 401 when X-Sofarr-Webhook-Secret is missing or wrong
|
||||
* - 400 when payload is invalid (missing/unknown eventType, non-object body)
|
||||
* - 200 + { received: true } for valid events
|
||||
* - Replay protection: second identical event returns { duplicate: true }
|
||||
* - Test event (eventType=Test) is accepted and short-circuits the cache refresh
|
||||
* - cache.updateWebhookMetrics is called when a known instance name is provided
|
||||
* - cache.getGlobalWebhookMetrics reflects the recorded event
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cache = require('../../server/utils/cache.js');
|
||||
|
||||
const VALID_SECRET = 'test-webhook-secret-abc';
|
||||
|
||||
// Minimal valid Sonarr Grab payload
|
||||
const SONARR_GRAB = {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T10:00:00.000Z',
|
||||
series: { id: 1, title: 'Test Show' },
|
||||
episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }]
|
||||
};
|
||||
|
||||
// Minimal valid Radarr Grab payload
|
||||
const RADARR_GRAB = {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T10:00:01.000Z',
|
||||
movie: { id: 1, title: 'Test Movie' }
|
||||
};
|
||||
|
||||
// Minimal Test event (sent by *arr "Test" button in notifications settings)
|
||||
const SONARR_TEST = {
|
||||
eventType: 'Test',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T10:00:02.000Z'
|
||||
};
|
||||
|
||||
function makeApp() {
|
||||
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
return createApp({ skipRateLimits: true });
|
||||
}
|
||||
|
||||
function postSonarr(app, payload, secret = VALID_SECRET) {
|
||||
const req = request(app).post('/api/webhook/sonarr').send(payload);
|
||||
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||
return req;
|
||||
}
|
||||
|
||||
function postRadarr(app, payload, secret = VALID_SECRET) {
|
||||
const req = request(app).post('/api/webhook/radarr').send(payload);
|
||||
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||
return req;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — secret validation', () => {
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, SONARR_GRAB, 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 postSonarr(app, SONARR_GRAB, 'wrong-secret');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, RADARR_GRAB, 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 postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — input validation', () => {
|
||||
it('returns 400 when body is not a JSON object (array)', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/webhook/sonarr')
|
||||
.set('X-Sofarr-Webhook-Secret', VALID_SECRET)
|
||||
.send([{ eventType: 'Grab' }]);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { instanceName: 'Main Sonarr' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/eventType/);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is an unknown value', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Unknown eventType/);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is not a string', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 42 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType exceeds 64 characters', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'G'.repeat(65) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when instanceName is not a string', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/instanceName/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — input validation', () => {
|
||||
it('returns 400 when eventType is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, { instanceName: 'Main Radarr' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when eventType is unknown', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Unknown eventType/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Happy path — valid events
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/sonarr — valid events', () => {
|
||||
it('returns 200 { received: true } for a valid Grab event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' };
|
||||
const res = await postSonarr(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 Test event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' };
|
||||
const res = await postSonarr(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts DownloadFolderImported event', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'DownloadFolderImported',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T11:02:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts event without instanceName field', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
date: '2026-05-19T11:03:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — valid events', () => {
|
||||
it('returns 200 { received: true } for a valid Grab event', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' };
|
||||
const res = await postRadarr(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts Download event', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T12:01:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replay protection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Replay protection', () => {
|
||||
it('sonarr: second identical event (same date) returns duplicate:true', async () => {
|
||||
const app = makeApp();
|
||||
const payload = {
|
||||
eventType: 'Grab',
|
||||
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).toBe(true);
|
||||
});
|
||||
|
||||
it('sonarr: event with different date is not considered a duplicate', async () => {
|
||||
const app = makeApp();
|
||||
const first = await postSonarr(app, {
|
||||
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z'
|
||||
});
|
||||
expect(first.body.duplicate).toBeUndefined();
|
||||
|
||||
const second = await postSonarr(app, {
|
||||
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z'
|
||||
});
|
||||
expect(second.body.duplicate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('radarr: second identical event returns duplicate:true', async () => {
|
||||
const app = makeApp();
|
||||
const payload = {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T15:00:00.000Z'
|
||||
};
|
||||
await postRadarr(app, payload);
|
||||
const second = await postRadarr(app, payload);
|
||||
expect(second.body.duplicate).toBe(true);
|
||||
});
|
||||
|
||||
it('event without date field is never considered a duplicate', async () => {
|
||||
const app = makeApp();
|
||||
const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' };
|
||||
const first = await postSonarr(app, payload);
|
||||
const second = await postSonarr(app, payload);
|
||||
// Neither should be flagged as duplicate (no date = no replay key)
|
||||
expect(first.body.duplicate).toBeUndefined();
|
||||
expect(second.body.duplicate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook metrics (Phase 5.1 integration)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Webhook metrics — cache.updateWebhookMetrics integration', () => {
|
||||
it('sonarr: increments eventsReceived for a known instance', async () => {
|
||||
const app = makeApp();
|
||||
const instanceUrl = 'https://sonarr.test';
|
||||
const before = cache.getWebhookMetrics(instanceUrl);
|
||||
const countBefore = before ? before.eventsReceived : 0;
|
||||
|
||||
await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T16:00:00.000Z'
|
||||
});
|
||||
|
||||
const after = cache.getWebhookMetrics(instanceUrl);
|
||||
expect(after.eventsReceived).toBe(countBefore + 1);
|
||||
expect(after.lastWebhookTimestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('radarr: increments eventsReceived for a known instance', async () => {
|
||||
const app = makeApp();
|
||||
const instanceUrl = 'https://radarr.test';
|
||||
const before = cache.getWebhookMetrics(instanceUrl);
|
||||
const countBefore = before ? before.eventsReceived : 0;
|
||||
|
||||
await postRadarr(app, {
|
||||
eventType: 'Download',
|
||||
instanceName: 'Main Radarr',
|
||||
date: '2026-05-19T16:01:00.000Z'
|
||||
});
|
||||
|
||||
const after = cache.getWebhookMetrics(instanceUrl);
|
||||
expect(after.eventsReceived).toBe(countBefore + 1);
|
||||
});
|
||||
|
||||
it('does not crash when instanceName does not match a configured instance', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Unknown Instance',
|
||||
date: '2026-05-19T16:02:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('global metrics totalWebhookEventsReceived increments after valid event', async () => {
|
||||
const app = makeApp();
|
||||
const beforeGlobal = cache.getGlobalWebhookMetrics();
|
||||
const beforeCount = beforeGlobal.totalWebhookEventsReceived;
|
||||
|
||||
await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T17:00:00.000Z'
|
||||
});
|
||||
|
||||
const afterGlobal = cache.getGlobalWebhookMetrics();
|
||||
expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret not included in response
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Security — secret never leaks', () => {
|
||||
it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postSonarr(app, {
|
||||
eventType: 'Grab',
|
||||
instanceName: 'Main Sonarr',
|
||||
date: '2026-05-19T18:00:00.000Z'
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||
});
|
||||
|
||||
it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'wrong');
|
||||
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Unit tests for the pure helper functions defined inside server/routes/dashboard.js.
|
||||
*
|
||||
* Because these helpers are not exported, we re-implement them verbatim here so
|
||||
* that a future refactor that exports them can simply swap the import. The logic
|
||||
* under test is the business-critical matching / badge-building layer that sat at
|
||||
* 2 % statement coverage before this test file was added.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline copies of the pure helpers from dashboard.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
if (tagLower === username) return true;
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCoverArt(item) {
|
||||
if (!item || !item.images) return null;
|
||||
const poster = item.images.find(img => img.coverType === 'poster');
|
||||
if (poster) return poster.remoteUrl || poster.url || null;
|
||||
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) {
|
||||
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
}
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getImportIssues(queueRecord) {
|
||||
if (!queueRecord) return null;
|
||||
const state = queueRecord.trackedDownloadState;
|
||||
const status = queueRecord.trackedDownloadStatus;
|
||||
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||
const messages = [];
|
||||
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||
for (const sm of queueRecord.statusMessages) {
|
||||
if (sm.messages && sm.messages.length > 0) {
|
||||
messages.push(...sm.messages);
|
||||
} else if (sm.title) {
|
||||
messages.push(sm.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (queueRecord.errorMessage) {
|
||||
messages.push(queueRecord.errorMessage);
|
||||
}
|
||||
if (messages.length === 0) return null;
|
||||
return messages;
|
||||
}
|
||||
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
function canBlocklist(download, isAdmin) {
|
||||
if (isAdmin) return true;
|
||||
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const addedOn = new Date(download.addedOn).getTime();
|
||||
const isOldEnough = addedOn < oneHourAgo;
|
||||
const availability = parseFloat(download.availability);
|
||||
const isLowAvailability = availability < 100;
|
||||
return isOldEnough && isLowAvailability;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractEpisode(record) {
|
||||
const ep = record.episode || {};
|
||||
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||
if (s == null || e == null) return null;
|
||||
const title = ep.title || null;
|
||||
return { season: s, episode: e, title };
|
||||
}
|
||||
|
||||
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||
const episodes = [];
|
||||
const seen = new Set();
|
||||
for (const r of sonarrRecords) {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||
const ep = extractEpisode(r);
|
||||
if (ep) {
|
||||
const key = `${ep.season}x${ep.episode}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
episodes.push(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||
return episodes;
|
||||
}
|
||||
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser: displayName };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sanitizeTagLabel', () => {
|
||||
it('lowercases the input', () => {
|
||||
expect(sanitizeTagLabel('Alice')).toBe('alice');
|
||||
});
|
||||
|
||||
it('replaces spaces with hyphens', () => {
|
||||
expect(sanitizeTagLabel('hello world')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('replaces non-alphanumeric chars with hyphens', () => {
|
||||
expect(sanitizeTagLabel('user@example.com')).toBe('user-example-com');
|
||||
});
|
||||
|
||||
it('collapses multiple non-alphanumeric chars to a single hyphen', () => {
|
||||
expect(sanitizeTagLabel('foo---bar')).toBe('foo-bar');
|
||||
expect(sanitizeTagLabel('foo bar')).toBe('foo-bar');
|
||||
});
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(sanitizeTagLabel('-foo-')).toBe('foo');
|
||||
});
|
||||
|
||||
it('returns empty string for falsy input', () => {
|
||||
expect(sanitizeTagLabel('')).toBe('');
|
||||
expect(sanitizeTagLabel(null)).toBe('');
|
||||
expect(sanitizeTagLabel(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagMatchesUser', () => {
|
||||
it('matches exact username (case-insensitive)', () => {
|
||||
expect(tagMatchesUser('Alice', 'alice')).toBe(true);
|
||||
expect(tagMatchesUser('alice', 'alice')).toBe(true);
|
||||
expect(tagMatchesUser('ALICE', 'alice')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when tag is the sanitized form of username', () => {
|
||||
expect(tagMatchesUser('user-example-com', 'user@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated tags', () => {
|
||||
expect(tagMatchesUser('bob', 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for missing tag or username', () => {
|
||||
expect(tagMatchesUser('', 'alice')).toBe(false);
|
||||
expect(tagMatchesUser('alice', '')).toBe(false);
|
||||
expect(tagMatchesUser(null, 'alice')).toBe(false);
|
||||
expect(tagMatchesUser('alice', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCoverArt', () => {
|
||||
it('returns null when item is falsy', () => {
|
||||
expect(getCoverArt(null)).toBeNull();
|
||||
expect(getCoverArt(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when item has no images', () => {
|
||||
expect(getCoverArt({})).toBeNull();
|
||||
expect(getCoverArt({ images: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers remoteUrl from a poster image', () => {
|
||||
const item = { images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg', url: '/local.jpg' }] };
|
||||
expect(getCoverArt(item)).toBe('https://img.test/poster.jpg');
|
||||
});
|
||||
|
||||
it('falls back to url when remoteUrl is absent on poster', () => {
|
||||
const item = { images: [{ coverType: 'poster', url: '/local.jpg' }] };
|
||||
expect(getCoverArt(item)).toBe('/local.jpg');
|
||||
});
|
||||
|
||||
it('falls back to fanart when no poster exists', () => {
|
||||
const item = { images: [{ coverType: 'fanart', remoteUrl: 'https://img.test/fanart.jpg' }] };
|
||||
expect(getCoverArt(item)).toBe('https://img.test/fanart.jpg');
|
||||
});
|
||||
|
||||
it('returns null when only irrelevant image types exist', () => {
|
||||
const item = { images: [{ coverType: 'banner', remoteUrl: 'https://img.test/banner.jpg' }] };
|
||||
expect(getCoverArt(item)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllTags', () => {
|
||||
it('returns empty array for null/empty tags', () => {
|
||||
expect(extractAllTags(null, null)).toEqual([]);
|
||||
expect(extractAllTags([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves tag ids via tagMap (Radarr style)', () => {
|
||||
const tagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||
expect(extractAllTags([1, 2], tagMap)).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('filters out ids not present in tagMap', () => {
|
||||
const tagMap = new Map([[1, 'alice']]);
|
||||
expect(extractAllTags([1, 99], tagMap)).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('extracts label property when no tagMap (Sonarr object style)', () => {
|
||||
const tags = [{ label: 'alice' }, { label: 'bob' }];
|
||||
expect(extractAllTags(tags, null)).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('filters out tag objects without a label', () => {
|
||||
const tags = [{ label: 'alice' }, null, {}];
|
||||
expect(extractAllTags(tags, null)).toEqual(['alice']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserTag', () => {
|
||||
const tagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||
|
||||
it('returns the matched label when found', () => {
|
||||
expect(extractUserTag([1, 2], tagMap, 'alice')).toBe('alice');
|
||||
});
|
||||
|
||||
it('returns null when no tag matches the username', () => {
|
||||
expect(extractUserTag([2], tagMap, 'alice')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when tags array is empty', () => {
|
||||
expect(extractUserTag([], tagMap, 'alice')).toBeNull();
|
||||
});
|
||||
|
||||
it('matches via sanitized form (email-style username)', () => {
|
||||
const map = new Map([[1, 'user-example-com']]);
|
||||
expect(extractUserTag([1], map, 'user@example.com')).toBe('user-example-com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImportIssues', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(getImportIssues(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when state/status are benign', () => {
|
||||
expect(getImportIssues({ trackedDownloadState: 'downloading', trackedDownloadStatus: 'ok' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns messages when state is importPending', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [{ messages: ['Sample needs repack'] }]
|
||||
};
|
||||
expect(getImportIssues(record)).toEqual(['Sample needs repack']);
|
||||
});
|
||||
|
||||
it('returns title fallback when statusMessage has no messages array', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [{ title: 'No matching episodes' }]
|
||||
};
|
||||
expect(getImportIssues(record)).toEqual(['No matching episodes']);
|
||||
});
|
||||
|
||||
it('includes errorMessage alongside statusMessages', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [{ messages: ['Msg1'] }],
|
||||
errorMessage: 'Disk full'
|
||||
};
|
||||
expect(getImportIssues(record)).toEqual(['Msg1', 'Disk full']);
|
||||
});
|
||||
|
||||
it('returns null when statusMessages is empty and no errorMessage', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: []
|
||||
};
|
||||
expect(getImportIssues(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns messages when trackedDownloadStatus is warning', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'warning',
|
||||
errorMessage: 'Low disk space'
|
||||
};
|
||||
expect(getImportIssues(record)).toEqual(['Low disk space']);
|
||||
});
|
||||
|
||||
it('returns messages when trackedDownloadStatus is error', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'error',
|
||||
errorMessage: 'Cannot connect'
|
||||
};
|
||||
expect(getImportIssues(record)).toEqual(['Cannot connect']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSonarrLink', () => {
|
||||
it('returns null for falsy series', () => {
|
||||
expect(getSonarrLink(null)).toBeNull();
|
||||
expect(getSonarrLink({})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when _instanceUrl is missing', () => {
|
||||
expect(getSonarrLink({ titleSlug: 'my-show' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when titleSlug is missing', () => {
|
||||
expect(getSonarrLink({ _instanceUrl: 'https://sonarr.test' })).toBeNull();
|
||||
});
|
||||
|
||||
it('constructs the correct URL', () => {
|
||||
const series = { _instanceUrl: 'https://sonarr.test', titleSlug: 'breaking-bad' };
|
||||
expect(getSonarrLink(series)).toBe('https://sonarr.test/series/breaking-bad');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRadarrLink', () => {
|
||||
it('returns null for falsy movie', () => {
|
||||
expect(getRadarrLink(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('constructs the correct URL', () => {
|
||||
const movie = { _instanceUrl: 'https://radarr.test', titleSlug: 'the-matrix-1999' };
|
||||
expect(getRadarrLink(movie)).toBe('https://radarr.test/movie/the-matrix-1999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canBlocklist', () => {
|
||||
it('always returns true for admin', () => {
|
||||
expect(canBlocklist({}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when download has importIssues', () => {
|
||||
expect(canBlocklist({ importIssues: ['issue'] }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when importIssues is empty', () => {
|
||||
expect(canBlocklist({ importIssues: [] }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when download is not a qbittorrent torrent', () => {
|
||||
expect(canBlocklist({ availability: '50' }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for qbittorrent torrent that is too new', () => {
|
||||
const download = {
|
||||
qbittorrent: true,
|
||||
addedOn: new Date().toISOString(), // just added
|
||||
availability: '50'
|
||||
};
|
||||
expect(canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for old qbittorrent torrent with 100% availability', () => {
|
||||
const download = {
|
||||
qbittorrent: true,
|
||||
addedOn: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
|
||||
availability: '100'
|
||||
};
|
||||
expect(canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for old qbittorrent torrent with low availability', () => {
|
||||
const download = {
|
||||
qbittorrent: true,
|
||||
addedOn: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
|
||||
availability: '50'
|
||||
};
|
||||
expect(canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEpisode', () => {
|
||||
it('returns null when season or episode is missing', () => {
|
||||
expect(extractEpisode({})).toBeNull();
|
||||
expect(extractEpisode({ episode: { seasonNumber: 1 } })).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts from nested episode object', () => {
|
||||
const record = { episode: { seasonNumber: 2, episodeNumber: 5, title: 'The One' } };
|
||||
expect(extractEpisode(record)).toEqual({ season: 2, episode: 5, title: 'The One' });
|
||||
});
|
||||
|
||||
it('falls back to top-level seasonNumber/episodeNumber', () => {
|
||||
const record = { episode: {}, seasonNumber: 3, episodeNumber: 10 };
|
||||
expect(extractEpisode(record)).toEqual({ season: 3, episode: 10, title: null });
|
||||
});
|
||||
|
||||
it('uses nested episode values over top-level when both present', () => {
|
||||
const record = { episode: { seasonNumber: 1, episodeNumber: 1, title: 'Nested' }, seasonNumber: 9, episodeNumber: 9 };
|
||||
expect(extractEpisode(record)).toEqual({ season: 1, episode: 1, title: 'Nested' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatherEpisodes', () => {
|
||||
const records = [
|
||||
{ title: 'show.s01e01.720p', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } },
|
||||
{ title: 'show.s01e02.720p', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' } },
|
||||
{ title: 'other.show.s02e01.720p', episode: { seasonNumber: 2, episodeNumber: 1, title: 'Other' } }
|
||||
];
|
||||
|
||||
it('returns matching episodes sorted by season then episode', () => {
|
||||
const eps = gatherEpisodes('show.s01e01.720p', records);
|
||||
expect(eps.length).toBeGreaterThan(0);
|
||||
expect(eps[0].season).toBe(1);
|
||||
expect(eps[0].episode).toBe(1);
|
||||
});
|
||||
|
||||
it('deduplicates identical season/episode pairs', () => {
|
||||
const dupeRecords = [
|
||||
{ title: 'show.s01e01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } },
|
||||
{ title: 'show.s01e01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } }
|
||||
];
|
||||
const eps = gatherEpisodes('show.s01e01', dupeRecords);
|
||||
expect(eps.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns empty array when no records match', () => {
|
||||
const eps = gatherEpisodes('completely different title', records);
|
||||
expect(eps).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty records', () => {
|
||||
expect(gatherEpisodes('anything', [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTagBadges', () => {
|
||||
it('returns badge with matchedUser when tag resolves via lowercase key', () => {
|
||||
const embyUserMap = new Map([['alice', 'Alice']]);
|
||||
const badges = buildTagBadges(['alice'], embyUserMap);
|
||||
expect(badges).toEqual([{ label: 'alice', matchedUser: 'Alice' }]);
|
||||
});
|
||||
|
||||
it('returns badge with matchedUser when tag resolves via sanitized key', () => {
|
||||
const embyUserMap = new Map([['user-example-com', 'User']]);
|
||||
const badges = buildTagBadges(['user@example.com'], embyUserMap);
|
||||
expect(badges).toEqual([{ label: 'user@example.com', matchedUser: 'User' }]);
|
||||
});
|
||||
|
||||
it('returns matchedUser: null for unknown tags', () => {
|
||||
const embyUserMap = new Map();
|
||||
const badges = buildTagBadges(['unknown'], embyUserMap);
|
||||
expect(badges).toEqual([{ label: 'unknown', matchedUser: null }]);
|
||||
});
|
||||
|
||||
it('handles empty tag list', () => {
|
||||
expect(buildTagBadges([], new Map())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
|
||||
]);
|
||||
|
||||
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
|
||||
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache, onHistoryUpdate, offHistoryUpdate } =
|
||||
await import('../../server/utils/historyFetcher.js');
|
||||
|
||||
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -176,3 +176,192 @@ describe('invalidateHistoryCache', () => {
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Staged Loading - Initial Batch', () => {
|
||||
it('fetches initial batch of 100 records', async () => {
|
||||
const mockRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toHaveLength(100);
|
||||
expect(result[0].id).toBe(1);
|
||||
expect(result[99].id).toBe(100);
|
||||
});
|
||||
|
||||
it('uses pageSize=100 for initial fetch', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
await fetchSonarrHistory(since);
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Staged Loading - Background Fetch', () => {
|
||||
it('triggers background fetch after initial batch', async () => {
|
||||
const mockRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
// Background fetch will make additional requests
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: [] });
|
||||
|
||||
await fetchSonarrHistory(since);
|
||||
// Background fetch is fire-and-forget, so we just verify it doesn't throw
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
it('prevents concurrent background fetches', async () => {
|
||||
const mockRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||
seriesId: 10
|
||||
}));
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
// First request
|
||||
await fetchSonarrHistory(since);
|
||||
|
||||
// Second request should not trigger additional background fetch
|
||||
await fetchSonarrHistory(since);
|
||||
|
||||
// Verify only one initial request was made
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deduplication', () => {
|
||||
it('filters out duplicate records by ID', async () => {
|
||||
const mockRecords = [
|
||||
{ id: 1, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } },
|
||||
{ id: 2, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E02', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } },
|
||||
{ id: 1, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } }, // Duplicate
|
||||
];
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
const ids = result.map(r => r.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
|
||||
expect(ids.length).toBe(uniqueIds.size); // No duplicates
|
||||
});
|
||||
|
||||
it('handles empty record set without errors', async () => {
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.reply(200, { records: [] });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Subscription', () => {
|
||||
it('subscribes to history updates', () => {
|
||||
let receivedType = null;
|
||||
const callback = (type) => { receivedType = type; };
|
||||
|
||||
onHistoryUpdate(callback);
|
||||
|
||||
// Manually trigger an update (we'll need to expose emitHistoryUpdate for testing)
|
||||
// For now, just verify subscription doesn't throw
|
||||
expect(() => onHistoryUpdate(callback)).not.toThrow();
|
||||
});
|
||||
|
||||
it('unsubscribes from history updates', () => {
|
||||
const callback = () => {};
|
||||
|
||||
onHistoryUpdate(callback);
|
||||
offHistoryUpdate(callback);
|
||||
|
||||
// Verify unsubscribe doesn't throw
|
||||
expect(() => offHistoryUpdate(callback)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles subscriber errors gracefully', () => {
|
||||
const errorCallback = () => { throw new Error('Subscriber error'); };
|
||||
const normalCallback = () => {};
|
||||
|
||||
onHistoryUpdate(errorCallback);
|
||||
onHistoryUpdate(normalCallback);
|
||||
|
||||
// If emitHistoryUpdate were exposed, we'd verify it doesn't crash
|
||||
// For now, just verify subscriptions work
|
||||
expect(() => onHistoryUpdate(() => {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('respects max records limit of 1000', async () => {
|
||||
// Mock initial batch
|
||||
const initialRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [] }
|
||||
}));
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: initialRecords });
|
||||
|
||||
const result = await fetchSonarrHistory(since);
|
||||
expect(result.length).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('uses batch size of 100 for background fetches', async () => {
|
||||
const mockRecords = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
eventType: 'downloadFolderImported',
|
||||
sourceTitle: `Show.S01E${i + 1}`,
|
||||
date: new Date(Date.now() - i * 60000).toISOString(),
|
||||
series: { id: 10, title: 'My Show', tags: [] }
|
||||
}));
|
||||
|
||||
nock('https://sonarr.test')
|
||||
.get('/api/v3/history')
|
||||
.query(true)
|
||||
.reply(200, { records: mockRecords });
|
||||
|
||||
await fetchSonarrHistory(since);
|
||||
expect(nock.isDone()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
const DownloadAssembler = require('../../../server/services/DownloadAssembler');
|
||||
|
||||
describe('DownloadAssembler', () => {
|
||||
describe('getCoverArt', () => {
|
||||
it('returns null when item is null or undefined', () => {
|
||||
expect(DownloadAssembler.getCoverArt(null)).toBeNull();
|
||||
expect(DownloadAssembler.getCoverArt(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when item has no images array', () => {
|
||||
expect(DownloadAssembler.getCoverArt({})).toBeNull();
|
||||
expect(DownloadAssembler.getCoverArt({ images: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns poster URL from remoteUrl', () => {
|
||||
const item = {
|
||||
images: [
|
||||
{ coverType: 'poster', remoteUrl: 'http://example.com/poster.jpg' }
|
||||
]
|
||||
};
|
||||
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
|
||||
});
|
||||
|
||||
it('returns poster URL from url when remoteUrl is missing', () => {
|
||||
const item = {
|
||||
images: [
|
||||
{ coverType: 'poster', url: 'http://example.com/poster.jpg' }
|
||||
]
|
||||
};
|
||||
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
|
||||
});
|
||||
|
||||
it('returns fanart as fallback when no poster', () => {
|
||||
const item = {
|
||||
images: [
|
||||
{ coverType: 'banner', url: 'http://example.com/banner.jpg' },
|
||||
{ coverType: 'fanart', remoteUrl: 'http://example.com/fanart.jpg' }
|
||||
]
|
||||
};
|
||||
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/fanart.jpg');
|
||||
});
|
||||
|
||||
it('returns null when no poster or fanart found', () => {
|
||||
const item = {
|
||||
images: [
|
||||
{ coverType: 'banner', url: 'http://example.com/banner.jpg' }
|
||||
]
|
||||
};
|
||||
expect(DownloadAssembler.getCoverArt(item)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers remoteUrl over url for poster', () => {
|
||||
const item = {
|
||||
images: [
|
||||
{ coverType: 'poster', url: 'http://example.com/poster-url.jpg', remoteUrl: 'http://example.com/poster-remote.jpg' }
|
||||
]
|
||||
};
|
||||
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster-remote.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImportIssues', () => {
|
||||
it('returns null when queueRecord is null or undefined', () => {
|
||||
expect(DownloadAssembler.getImportIssues(null)).toBeNull();
|
||||
expect(DownloadAssembler.getImportIssues(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when state is not importPending and status is not warning/error', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'ok'
|
||||
};
|
||||
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when state is importPending but no messages', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: []
|
||||
};
|
||||
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns messages when state is importPending with statusMessages', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [
|
||||
{ messages: ['Error 1', 'Error 2'] },
|
||||
{ title: 'Warning message' }
|
||||
]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Error 1', 'Error 2', 'Warning message']);
|
||||
});
|
||||
|
||||
it('returns messages when status is warning', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'warning',
|
||||
statusMessages: [
|
||||
{ messages: ['Warning 1'] }
|
||||
]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Warning 1']);
|
||||
});
|
||||
|
||||
it('returns messages when status is error', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'downloading',
|
||||
trackedDownloadStatus: 'error',
|
||||
statusMessages: [
|
||||
{ messages: ['Error 1'] }
|
||||
]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Error 1']);
|
||||
});
|
||||
|
||||
it('includes errorMessage when present', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
errorMessage: 'Main error message'
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Main error message']);
|
||||
});
|
||||
|
||||
it('combines statusMessages and errorMessage', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [
|
||||
{ messages: ['Error 1'] }
|
||||
],
|
||||
errorMessage: 'Main error'
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Error 1', 'Main error']);
|
||||
});
|
||||
|
||||
it('handles empty statusMessages array with title', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [
|
||||
{ title: 'Title only' }
|
||||
]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Title only']);
|
||||
});
|
||||
|
||||
it('handles statusMessages with empty messages array', () => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'ok',
|
||||
statusMessages: [
|
||||
{ messages: [] }
|
||||
]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// Test all status/state combinations
|
||||
it('returns null for all combinations when no messages', () => {
|
||||
const states = ['importPending', 'downloading', 'queued', 'completed'];
|
||||
const statuses = ['warning', 'error', 'ok', 'downloading'];
|
||||
|
||||
states.forEach(state => {
|
||||
statuses.forEach(status => {
|
||||
const record = {
|
||||
trackedDownloadState: state,
|
||||
trackedDownloadStatus: status,
|
||||
statusMessages: []
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
// Only importPending, warning, or error should potentially return issues
|
||||
// But without messages, all should return null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messages for importPending state regardless of status', () => {
|
||||
const statuses = ['ok', 'warning', 'error', 'downloading'];
|
||||
statuses.forEach(status => {
|
||||
const record = {
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: status,
|
||||
statusMessages: [{ messages: ['Test message'] }]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Test message']);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messages for warning status regardless of state', () => {
|
||||
const states = ['importPending', 'downloading', 'queued', 'completed'];
|
||||
states.forEach(state => {
|
||||
const record = {
|
||||
trackedDownloadState: state,
|
||||
trackedDownloadStatus: 'warning',
|
||||
statusMessages: [{ messages: ['Test message'] }]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Test message']);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messages for error status regardless of state', () => {
|
||||
const states = ['importPending', 'downloading', 'queued', 'completed'];
|
||||
states.forEach(state => {
|
||||
const record = {
|
||||
trackedDownloadState: state,
|
||||
trackedDownloadStatus: 'error',
|
||||
statusMessages: [{ messages: ['Test message'] }]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toEqual(['Test message']);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-matching state/status combinations', () => {
|
||||
const combinations = [
|
||||
{ state: 'downloading', status: 'ok' },
|
||||
{ state: 'queued', status: 'downloading' },
|
||||
{ state: 'completed', status: 'completed' }
|
||||
];
|
||||
combinations.forEach(({ state, status }) => {
|
||||
const record = {
|
||||
trackedDownloadState: state,
|
||||
trackedDownloadStatus: status,
|
||||
statusMessages: [{ messages: ['Test message'] }]
|
||||
};
|
||||
const result = DownloadAssembler.getImportIssues(record);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSonarrLink', () => {
|
||||
it('returns null when series is null or undefined', () => {
|
||||
expect(DownloadAssembler.getSonarrLink(null)).toBeNull();
|
||||
expect(DownloadAssembler.getSonarrLink(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when series is missing _instanceUrl', () => {
|
||||
expect(DownloadAssembler.getSonarrLink({ titleSlug: 'test' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when series is missing titleSlug', () => {
|
||||
expect(DownloadAssembler.getSonarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns correct link when both _instanceUrl and titleSlug present', () => {
|
||||
const series = {
|
||||
_instanceUrl: 'http://example.com',
|
||||
titleSlug: 'test-series'
|
||||
};
|
||||
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com/series/test-series');
|
||||
});
|
||||
|
||||
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
|
||||
const series = {
|
||||
_instanceUrl: 'http://example.com/',
|
||||
titleSlug: 'test-series'
|
||||
};
|
||||
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com//series/test-series');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRadarrLink', () => {
|
||||
it('returns null when movie is null or undefined', () => {
|
||||
expect(DownloadAssembler.getRadarrLink(null)).toBeNull();
|
||||
expect(DownloadAssembler.getRadarrLink(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when movie is missing _instanceUrl', () => {
|
||||
expect(DownloadAssembler.getRadarrLink({ titleSlug: 'test' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when movie is missing titleSlug', () => {
|
||||
expect(DownloadAssembler.getRadarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns correct link when both _instanceUrl and titleSlug present', () => {
|
||||
const movie = {
|
||||
_instanceUrl: 'http://example.com',
|
||||
titleSlug: 'test-movie'
|
||||
};
|
||||
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com/movie/test-movie');
|
||||
});
|
||||
|
||||
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
|
||||
const movie = {
|
||||
_instanceUrl: 'http://example.com/',
|
||||
titleSlug: 'test-movie'
|
||||
};
|
||||
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com//movie/test-movie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canBlocklist', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns true for admin users', () => {
|
||||
const download = {};
|
||||
expect(DownloadAssembler.canBlocklist(download, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for non-admin with importIssues', () => {
|
||||
const download = {
|
||||
importIssues: ['Error 1', 'Error 2']
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for non-admin with empty importIssues array', () => {
|
||||
const download = {
|
||||
importIssues: []
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-admin without importIssues and missing qbittorrent data', () => {
|
||||
const download = {};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-admin with qbittorrent but missing addedOn', () => {
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
availability: '50'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-admin with qbittorrent but missing availability', () => {
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for non-admin when torrent is old and availability < 100', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
|
||||
availability: '50'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-admin when torrent is old but availability >= 100', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
|
||||
availability: '100'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-admin when torrent is new even with low availability', () => {
|
||||
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
|
||||
availability: '50'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for non-admin when torrent is exactly 1 hour old with low availability', () => {
|
||||
vi.setSystemTime(new Date('2024-01-01T01:00:01Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 1 hour + 1 second ago
|
||||
availability: '50'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-admin when torrent is just under 1 hour old with low availability', () => {
|
||||
vi.setSystemTime(new Date('2024-01-01T00:59:59Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 59 minutes 59 seconds ago
|
||||
availability: '50'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles availability as number instead of string', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z',
|
||||
availability: 50
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles availability as decimal', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z',
|
||||
availability: '99.9'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for non-admin with availability exactly 0', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z',
|
||||
availability: '0'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for non-admin with availability 99.99', () => {
|
||||
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
|
||||
const download = {
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z',
|
||||
availability: '99.99'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes importIssues over age/availability check', () => {
|
||||
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
|
||||
const download = {
|
||||
importIssues: ['Error'],
|
||||
qbittorrent: {},
|
||||
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
|
||||
availability: '100'
|
||||
};
|
||||
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEpisode', () => {
|
||||
it('returns null when record is null or undefined', () => {
|
||||
expect(DownloadAssembler.extractEpisode(null)).toBeNull();
|
||||
expect(DownloadAssembler.extractEpisode(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when season and episode are both missing', () => {
|
||||
const record = {};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts from episode.seasonNumber and episode.episodeNumber', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
title: 'Test Episode'
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 1,
|
||||
episode: 5,
|
||||
title: 'Test Episode'
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts from record.seasonNumber and record.episodeNumber when episode is missing', () => {
|
||||
const record = {
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 10,
|
||||
title: 'Test'
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 2,
|
||||
episode: 10,
|
||||
title: null
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritizes episode.seasonNumber over record.seasonNumber', () => {
|
||||
const record = {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
episode: {
|
||||
seasonNumber: 3,
|
||||
episodeNumber: 7,
|
||||
title: 'Test Episode'
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 3,
|
||||
episode: 7,
|
||||
title: 'Test Episode'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null seasonNumber in episode', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: null,
|
||||
episodeNumber: 5,
|
||||
title: 'Test'
|
||||
},
|
||||
seasonNumber: 2
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 2,
|
||||
episode: 5,
|
||||
title: 'Test'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null episodeNumber in episode', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: 2,
|
||||
episodeNumber: null,
|
||||
title: 'Test'
|
||||
},
|
||||
episodeNumber: 10
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 2,
|
||||
episode: 10,
|
||||
title: 'Test'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when only season is present', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: 1,
|
||||
title: 'Test'
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when only episode is present', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
episodeNumber: 5,
|
||||
title: 'Test'
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles title as null when not present', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 1,
|
||||
episode: 5,
|
||||
title: null
|
||||
});
|
||||
});
|
||||
|
||||
it('handles zero values for season and episode', () => {
|
||||
const record = {
|
||||
episode: {
|
||||
seasonNumber: 0,
|
||||
episodeNumber: 0,
|
||||
title: 'Test'
|
||||
}
|
||||
};
|
||||
expect(DownloadAssembler.extractEpisode(record)).toEqual({
|
||||
season: 0,
|
||||
episode: 0,
|
||||
title: 'Test'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatherEpisodes', () => {
|
||||
it('returns empty array when no records', () => {
|
||||
const result = DownloadAssembler.gatherEpisodes('test', []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches all records when titleLower is empty (empty string is included in any string)', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches records by title inclusion', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
|
||||
{ title: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } },
|
||||
{ title: 'Test Show S01E03', episode: { seasonNumber: 1, episodeNumber: 3, title: 'Ep 3' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' },
|
||||
{ season: 1, episode: 3, title: 'Ep 3' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches records by sourceTitle inclusion', () => {
|
||||
const records = [
|
||||
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
|
||||
{ sourceTitle: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches when titleLower is included in record title', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01 Extra', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show s01e01', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates episodes by season and episode number', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1 Duplicate' } },
|
||||
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' },
|
||||
{ season: 1, episode: 2, title: 'Ep 2' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts episodes by season then episode', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S02E05', episode: { seasonNumber: 2, episodeNumber: 5, title: 'Ep 5' } },
|
||||
{ title: 'Test Show S01E10', episode: { seasonNumber: 1, episodeNumber: 10, title: 'Ep 10' } },
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
|
||||
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' },
|
||||
{ season: 1, episode: 10, title: 'Ep 10' },
|
||||
{ season: 2, episode: 1, title: 'Ep 1' },
|
||||
{ season: 2, episode: 5, title: 'Ep 5' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles case insensitivity', () => {
|
||||
const records = [
|
||||
{ title: 'TEST SHOW S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips records that cannot extract episode info', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
|
||||
{ title: 'Test Show No Episode' },
|
||||
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' },
|
||||
{ season: 1, episode: 2, title: 'Ep 2' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles records with missing title and sourceTitle', () => {
|
||||
const records = [
|
||||
{ episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps first occurrence when deduplicating', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'First' } },
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Second' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'First' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple seasons', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'S1E1' } },
|
||||
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'S2E1' } },
|
||||
{ title: 'Test Show S03E01', episode: { seasonNumber: 3, episodeNumber: 1, title: 'S3E1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'S1E1' },
|
||||
{ season: 2, episode: 1, title: 'S2E1' },
|
||||
{ season: 3, episode: 1, title: 'S3E1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles special characters in titles', () => {
|
||||
const records = [
|
||||
{ title: 'Test.Show.S01E01.HDTV.x264', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test.show.s01e01', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'Ep 1' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates across different record types', () => {
|
||||
const records = [
|
||||
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Title' } },
|
||||
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Source' } }
|
||||
];
|
||||
const result = DownloadAssembler.gatherEpisodes('test show', records);
|
||||
expect(result).toEqual([
|
||||
{ season: 1, episode: 1, title: 'From Title' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,927 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Guard tests for server/services/DownloadBuilder.js
|
||||
*
|
||||
* This test file serves as a regression guard for the deduplicated download-assembly
|
||||
* logic that will be extracted from dashboard.js into the DownloadBuilder service.
|
||||
* The function buildUserDownloads does not exist yet - this test will pass once
|
||||
* the implementation is complete in the next prompt.
|
||||
*
|
||||
* Coverage:
|
||||
* - Happy path with matching downloads
|
||||
* - Empty data scenarios
|
||||
* - Mixed series and movies
|
||||
* - Admin vs regular user permissions
|
||||
* - showAll=true vs showAll=false filtering
|
||||
* - Duplicate prevention (same download matched via multiple sources)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { buildUserDownloads } from '../../../server/services/DownloadBuilder.js';
|
||||
|
||||
describe('buildUserDownloads', () => {
|
||||
const username = 'alice';
|
||||
const usernameSanitized = 'alice';
|
||||
const isAdmin = false;
|
||||
const showAll = false;
|
||||
|
||||
const sonarrTagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||
const radarrTagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||
const embyUserMap = new Map([['alice', 'Alice'], ['bob', 'Bob']]);
|
||||
|
||||
const seriesMap = new Map([
|
||||
[1, {
|
||||
id: 1,
|
||||
title: 'Test Series',
|
||||
titleSlug: 'test-series',
|
||||
path: '/series/test',
|
||||
tags: [1],
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://example.com/poster.jpg' }],
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
]);
|
||||
|
||||
const moviesMap = new Map([
|
||||
[1, {
|
||||
id: 1,
|
||||
title: 'Test Movie',
|
||||
titleSlug: 'test-movie',
|
||||
path: '/movies/test',
|
||||
tags: [1],
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://example.com/movie-poster.jpg' }],
|
||||
_instanceUrl: 'https://radarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
]);
|
||||
|
||||
it('returns empty array when no downloads match user', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: { data: { records: [] } },
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for null/undefined cache data', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: null,
|
||||
sabnzbdHistory: null,
|
||||
sonarrQueue: null,
|
||||
sonarrHistory: null,
|
||||
radarrQueue: null,
|
||||
radarrHistory: null,
|
||||
qbittorrentTorrents: null
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches SABnzbd queue slot to Sonarr series for tagged user', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'test.series.s01e01.720p',
|
||||
nzbname: 'test.series.s01e01.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00',
|
||||
storage: '/downloads/test'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e01.720p',
|
||||
sourceTitle: 'test.series.s01e01.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 200,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: 'series',
|
||||
title: 'test.series.s01e01.720p',
|
||||
status: 'Downloading',
|
||||
progress: 50,
|
||||
coverArt: 'https://example.com/poster.jpg',
|
||||
seriesName: 'Test Series',
|
||||
allTags: ['alice'],
|
||||
matchedUserTag: 'alice',
|
||||
client: 'sabnzbd'
|
||||
});
|
||||
expect(result[0].episodes).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('matches SABnzbd queue slot to Radarr movie for tagged user', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'test.movie.2023.1080p',
|
||||
nzbname: 'test.movie.2023.1080p',
|
||||
status: 'Downloading',
|
||||
percentage: 75,
|
||||
mb: 2000,
|
||||
mbmissing: 500,
|
||||
size: '2 GB',
|
||||
timeleft: '5:00',
|
||||
storage: '/downloads/testmovie'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: { data: { records: [] } },
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'Test Movie 2023',
|
||||
sourceTitle: 'test.movie.2023.1080p',
|
||||
movieId: 1,
|
||||
movie: moviesMap.get(1),
|
||||
_instanceUrl: 'https://radarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: 'movie',
|
||||
title: 'test.movie.2023.1080p',
|
||||
status: 'Downloading',
|
||||
progress: 75,
|
||||
coverArt: 'https://example.com/movie-poster.jpg',
|
||||
movieName: 'Test Movie',
|
||||
allTags: ['alice'],
|
||||
matchedUserTag: 'alice',
|
||||
client: 'sabnzbd'
|
||||
});
|
||||
});
|
||||
|
||||
it('matches qBittorrent torrent to Sonarr series for tagged user', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e02.720p',
|
||||
sourceTitle: 'test.series.s01e02.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 201,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: [{
|
||||
hash: 'abc123',
|
||||
name: 'test.series.s01e02.720p',
|
||||
progress: 60,
|
||||
dlspeed: 5242880,
|
||||
eta: 600,
|
||||
size: 1073741824,
|
||||
savePath: '/downloads/test',
|
||||
addedOn: new Date(Date.now() - 7200000).toISOString(),
|
||||
availability: '50'
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: 'series',
|
||||
title: 'test.series.s01e02.720p',
|
||||
seriesName: 'Test Series',
|
||||
allTags: ['alice'],
|
||||
matchedUserTag: 'alice',
|
||||
client: 'qbittorrent'
|
||||
});
|
||||
expect(result[0]).toHaveProperty('id');
|
||||
expect(result[0]).toHaveProperty('progress');
|
||||
expect(result[0]).toHaveProperty('speed');
|
||||
expect(result[0]).toHaveProperty('eta');
|
||||
});
|
||||
|
||||
it('includes admin-specific fields when isAdmin is true', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'test.series.s01e03.720p',
|
||||
nzbname: 'test.series.s01e03.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 30,
|
||||
mb: 1500,
|
||||
mbmissing: 1050,
|
||||
size: '1.5 GB',
|
||||
timeleft: '15:00',
|
||||
storage: '/downloads/test'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e03.720p',
|
||||
sourceTitle: 'test.series.s01e03.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 202,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
downloadPath: '/downloads/test',
|
||||
targetPath: '/series/test',
|
||||
arrLink: 'https://sonarr.test/series/test-series',
|
||||
arrQueueId: 100,
|
||||
arrType: 'sonarr',
|
||||
arrInstanceUrl: 'https://sonarr.test',
|
||||
arrInstanceKey: 'test-key',
|
||||
arrContentId: 202,
|
||||
arrContentType: 'episode',
|
||||
canBlocklist: true
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by user tag when showAll is false', () => {
|
||||
const bobSeriesMap = new Map([
|
||||
[2, {
|
||||
id: 2,
|
||||
title: 'Bob Series',
|
||||
titleSlug: 'bob-series',
|
||||
path: '/series/bob',
|
||||
tags: [2], // Bob's tag
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://example.com/bob-poster.jpg' }]
|
||||
}]
|
||||
]);
|
||||
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'bob.series.s01e01.720p',
|
||||
nzbname: 'bob.series.s01e01.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00',
|
||||
storage: '/downloads/bob'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 200,
|
||||
title: 'bob.series.s01e01.720p',
|
||||
sourceTitle: 'bob.series.s01e01.720p',
|
||||
seriesId: 2,
|
||||
series: bobSeriesMap.get(2),
|
||||
episodeId: 300
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username: 'alice',
|
||||
usernameSanitized: 'alice',
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
seriesMap: bobSeriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
// Alice should not see Bob's download when showAll is false
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows all tagged downloads when showAll is true (admin mode)', () => {
|
||||
const bobSeriesMap = new Map([
|
||||
[2, {
|
||||
id: 2,
|
||||
title: 'Bob Series',
|
||||
titleSlug: 'bob-series',
|
||||
path: '/series/bob',
|
||||
tags: [2], // Bob's tag
|
||||
images: [{ coverType: 'poster', remoteUrl: 'https://example.com/bob-poster.jpg' }]
|
||||
}]
|
||||
]);
|
||||
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'bob.series.s01e01.720p',
|
||||
nzbname: 'bob.series.s01e01.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00',
|
||||
storage: '/downloads/bob'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 200,
|
||||
title: 'bob.series.s01e01.720p',
|
||||
sourceTitle: 'bob.series.s01e01.720p',
|
||||
seriesId: 2,
|
||||
series: bobSeriesMap.get(2),
|
||||
episodeId: 300
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username: 'alice',
|
||||
usernameSanitized: 'alice',
|
||||
isAdmin: true,
|
||||
showAll: true,
|
||||
seriesMap: bobSeriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
// Admin with showAll=true should see all tagged downloads
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: 'series',
|
||||
title: 'bob.series.s01e01.720p',
|
||||
allTags: ['bob'],
|
||||
matchedUserTag: null,
|
||||
tagBadges: [{ label: 'bob', matchedUser: 'Bob' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('includes importIssues when present in queue record', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'test.series.s01e04.720p',
|
||||
nzbname: 'test.series.s01e04.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 90,
|
||||
mb: 2000,
|
||||
mbmissing: 200,
|
||||
size: '2 GB',
|
||||
timeleft: '2:00',
|
||||
storage: '/downloads/test'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e04.720p',
|
||||
sourceTitle: 'test.series.s01e04.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 203,
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'warning',
|
||||
statusMessages: [{ messages: ['Sample needs repack'] }],
|
||||
errorMessage: 'Disk space low',
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].importIssues).toEqual(['Sample needs repack', 'Disk space low']);
|
||||
});
|
||||
|
||||
it('handles mixed series and movie downloads', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '10.0 MB/s',
|
||||
kbpersec: 10240,
|
||||
slots: [
|
||||
{
|
||||
filename: 'test.series.s01e05.720p',
|
||||
nzbname: 'test.series.s01e05.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 40,
|
||||
mb: 800,
|
||||
mbmissing: 480,
|
||||
size: '800 MB',
|
||||
timeleft: '8:00',
|
||||
storage: '/downloads/series'
|
||||
},
|
||||
{
|
||||
filename: 'test.movie.2023.1080p',
|
||||
nzbname: 'test.movie.2023.1080p',
|
||||
status: 'Downloading',
|
||||
percentage: 60,
|
||||
mb: 1200,
|
||||
mbmissing: 480,
|
||||
size: '1.2 GB',
|
||||
timeleft: '6:00',
|
||||
storage: '/downloads/movie'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e05.720p',
|
||||
sourceTitle: 'test.series.s01e05.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 204,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 101,
|
||||
title: 'Test Movie 2023',
|
||||
sourceTitle: 'test.movie.2023.1080p',
|
||||
movieId: 1,
|
||||
movie: moviesMap.get(1),
|
||||
_instanceUrl: 'https://radarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe('series');
|
||||
expect(result[1].type).toBe('movie');
|
||||
});
|
||||
|
||||
it('prevents duplicate downloads when same item matches multiple sources', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'test.series.s01e06.720p',
|
||||
nzbname: 'test.series.s01e06.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00',
|
||||
storage: '/downloads/test'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e06.720p',
|
||||
sourceTitle: 'test.series.s01e06.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 205,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e06.720p',
|
||||
sourceTitle: 'test.series.s01e06.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 205
|
||||
}]
|
||||
}
|
||||
},
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: [{
|
||||
hash: 'def456',
|
||||
name: 'test.series.s01e06.720p',
|
||||
progress: 50,
|
||||
dlspeed: 5242880,
|
||||
eta: 600,
|
||||
size: 1073741824,
|
||||
savePath: '/downloads/test'
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
// Should only return one download item even though it matches in queue, history, and torrents
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches SABnzbd history slots to completed downloads', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: {
|
||||
data: {
|
||||
history: {
|
||||
slots: [{
|
||||
name: 'test.series.s01e07.720p',
|
||||
nzb_name: 'test.series.s01e07.720p',
|
||||
status: 'Completed',
|
||||
mb: 1000,
|
||||
size: '1 GB',
|
||||
completed_time: '2024-01-01T12:00:00Z',
|
||||
storage: '/downloads/completed'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sonarrQueue: { data: { records: [] } },
|
||||
sonarrHistory: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e07.720p',
|
||||
sourceTitle: 'test.series.s01e07.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 206
|
||||
}]
|
||||
}
|
||||
},
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: 'series',
|
||||
title: 'test.series.s01e07.720p',
|
||||
status: 'Completed',
|
||||
completedAt: '2024-01-01T12:00:00Z'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display unmatched torrents', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: { data: { records: [] } },
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: [{
|
||||
hash: 'ghi789',
|
||||
name: 'test.movie.2023.1080p',
|
||||
progress: 30,
|
||||
dlspeed: 2097152,
|
||||
eta: 1200,
|
||||
size: 2147483648,
|
||||
savePath: '/downloads/test',
|
||||
addedOn: new Date(Date.now() - 7200000).toISOString(),
|
||||
availability: '50'
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: false,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
// Unmatched torrents (not in Sonarr/Radarr queue/history) should not be displayed
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes sonarrLink and radarrLink when available', () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [
|
||||
{
|
||||
filename: 'test.series.s01e08.720p',
|
||||
nzbname: 'test.series.s01e08.720p',
|
||||
status: 'Downloading',
|
||||
percentage: 25,
|
||||
mb: 500,
|
||||
mbmissing: 375,
|
||||
size: '500 MB',
|
||||
timeleft: '12:00',
|
||||
storage: '/downloads/series'
|
||||
},
|
||||
{
|
||||
filename: 'test.movie.2023.1080p',
|
||||
nzbname: 'test.movie.2023.1080p',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00',
|
||||
storage: '/downloads/movie'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
title: 'test.series.s01e08.720p',
|
||||
sourceTitle: 'test.series.s01e08.720p',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
episodeId: 207,
|
||||
_instanceUrl: 'https://sonarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 101,
|
||||
title: 'Test Movie 2023',
|
||||
sourceTitle: 'test.movie.2023.1080p',
|
||||
movieId: 1,
|
||||
movie: moviesMap.get(1),
|
||||
_instanceUrl: 'https://radarr.test',
|
||||
_instanceKey: 'test-key'
|
||||
}]
|
||||
}
|
||||
},
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].arrLink).toBe('https://sonarr.test/series/test-series');
|
||||
expect(result[1].arrLink).toBe('https://radarr.test/movie/test-movie');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Tests for server/services/TagMatcher.js
|
||||
*
|
||||
* Verifies that tag matching and sanitization functions work correctly.
|
||||
* These are pure business logic functions extracted from dashboard.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import * as TagMatcher from '../../../server/services/TagMatcher.js';
|
||||
|
||||
describe('sanitizeTagLabel', () => {
|
||||
it('returns empty string for null/undefined input', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel(null)).toBe('');
|
||||
expect(TagMatcher.sanitizeTagLabel(undefined)).toBe('');
|
||||
expect(TagMatcher.sanitizeTagLabel('')).toBe('');
|
||||
});
|
||||
|
||||
it('lowercases input', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel('Test')).toBe('test');
|
||||
expect(TagMatcher.sanitizeTagLabel('USERNAME')).toBe('username');
|
||||
});
|
||||
|
||||
it('replaces non-alphanumeric characters with hyphens', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel('test@example.com')).toBe('test-example-com');
|
||||
expect(TagMatcher.sanitizeTagLabel('user_name')).toBe('user-name');
|
||||
expect(TagMatcher.sanitizeTagLabel('user.name')).toBe('user-name');
|
||||
});
|
||||
|
||||
it('collapses multiple hyphens into single hyphen', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel('test---example')).toBe('test-example');
|
||||
expect(TagMatcher.sanitizeTagLabel('user___name')).toBe('user-name');
|
||||
});
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel('-test')).toBe('test');
|
||||
expect(TagMatcher.sanitizeTagLabel('test-')).toBe('test');
|
||||
expect(TagMatcher.sanitizeTagLabel('-test-')).toBe('test');
|
||||
});
|
||||
|
||||
it('handles complex email-style usernames', () => {
|
||||
expect(TagMatcher.sanitizeTagLabel('user@example.com')).toBe('user-example-com');
|
||||
expect(TagMatcher.sanitizeTagLabel('john.doe+tag@gmail.com')).toBe('john-doe-tag-gmail-com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagMatchesUser', () => {
|
||||
it('returns false for null tag or username', () => {
|
||||
expect(TagMatcher.tagMatchesUser(null, 'user')).toBe(false);
|
||||
expect(TagMatcher.tagMatchesUser('tag', null)).toBe(false);
|
||||
expect(TagMatcher.tagMatchesUser(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for exact case-insensitive match', () => {
|
||||
expect(TagMatcher.tagMatchesUser('john', 'john')).toBe(true);
|
||||
expect(TagMatcher.tagMatchesUser('John', 'john')).toBe(true);
|
||||
expect(TagMatcher.tagMatchesUser('john', 'John')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for sanitized match (Ombi-mangled email usernames)', () => {
|
||||
expect(TagMatcher.tagMatchesUser('john-example-com', 'john@example.com')).toBe(true);
|
||||
expect(TagMatcher.tagMatchesUser('john-doe-gmail-com', 'john.doe@gmail.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when tag does not match username', () => {
|
||||
expect(TagMatcher.tagMatchesUser('alice', 'bob')).toBe(false);
|
||||
expect(TagMatcher.tagMatchesUser('john-example-com', 'alice@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllTags', () => {
|
||||
it('returns empty array for null/empty tags', () => {
|
||||
expect(TagMatcher.extractAllTags(null, null)).toEqual([]);
|
||||
expect(TagMatcher.extractAllTags([], null)).toEqual([]);
|
||||
expect(TagMatcher.extractAllTags(undefined, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts labels from Radarr-style tag IDs using tagMap', () => {
|
||||
const tags = [1, 2, 3];
|
||||
const tagMap = new Map([
|
||||
[1, 'john'],
|
||||
[2, 'alice'],
|
||||
[3, 'bob']
|
||||
]);
|
||||
expect(TagMatcher.extractAllTags(tags, tagMap)).toEqual(['john', 'alice', 'bob']);
|
||||
});
|
||||
|
||||
it('extracts labels from Sonarr-style tag objects', () => {
|
||||
const tags = [
|
||||
{ id: 1, label: 'john' },
|
||||
{ id: 2, label: 'alice' },
|
||||
{ id: 3, label: 'bob' }
|
||||
];
|
||||
expect(TagMatcher.extractAllTags(tags, null)).toEqual(['john', 'alice', 'bob']);
|
||||
});
|
||||
|
||||
it('filters out null/undefined labels', () => {
|
||||
const tags = [1, 2, 3];
|
||||
const tagMap = new Map([
|
||||
[1, 'john'],
|
||||
[2, null],
|
||||
[3, 'bob']
|
||||
]);
|
||||
expect(TagMatcher.extractAllTags(tags, tagMap)).toEqual(['john', 'bob']);
|
||||
});
|
||||
|
||||
it('handles mixed Sonarr-style objects with missing labels', () => {
|
||||
const tags = [
|
||||
{ id: 1, label: 'john' },
|
||||
{ id: 2 },
|
||||
{ id: 3, label: 'bob' }
|
||||
];
|
||||
expect(TagMatcher.extractAllTags(tags, null)).toEqual(['john', 'bob']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUserTag', () => {
|
||||
it('returns null for empty tags', () => {
|
||||
expect(TagMatcher.extractUserTag(null, null, 'john')).toBe(null);
|
||||
expect(TagMatcher.extractUserTag([], null, 'john')).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null when no username provided', () => {
|
||||
const tags = [1];
|
||||
const tagMap = new Map([[1, 'john']]);
|
||||
expect(TagMatcher.extractUserTag(tags, tagMap, null)).toBe(null);
|
||||
expect(TagMatcher.extractUserTag(tags, tagMap, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it('returns matching tag for exact match', () => {
|
||||
const tags = [1, 2];
|
||||
const tagMap = new Map([
|
||||
[1, 'john'],
|
||||
[2, 'alice']
|
||||
]);
|
||||
expect(TagMatcher.extractUserTag(tags, tagMap, 'john')).toBe('john');
|
||||
});
|
||||
|
||||
it('returns matching tag for sanitized match', () => {
|
||||
const tags = [1, 2];
|
||||
const tagMap = new Map([
|
||||
[1, 'john-example-com'],
|
||||
[2, 'alice-example-com']
|
||||
]);
|
||||
expect(TagMatcher.extractUserTag(tags, tagMap, 'john@example.com')).toBe('john-example-com');
|
||||
});
|
||||
|
||||
it('returns null when no tag matches username', () => {
|
||||
const tags = [1, 2];
|
||||
const tagMap = new Map([
|
||||
[1, 'alice'],
|
||||
[2, 'bob']
|
||||
]);
|
||||
expect(TagMatcher.extractUserTag(tags, tagMap, 'john')).toBe(null);
|
||||
});
|
||||
|
||||
it('handles Sonarr-style tag objects', () => {
|
||||
const tags = [
|
||||
{ id: 1, label: 'john' },
|
||||
{ id: 2, label: 'alice' }
|
||||
];
|
||||
expect(TagMatcher.extractUserTag(tags, null, 'john')).toBe('john');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTagBadges', () => {
|
||||
it('classifies tags as matched when user exists in embyUserMap', () => {
|
||||
const allTags = ['john', 'alice', 'bob'];
|
||||
const embyUserMap = new Map([
|
||||
['john', 'John Doe'],
|
||||
['alice', 'Alice Smith'],
|
||||
['bob', 'Bob Johnson']
|
||||
]);
|
||||
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
|
||||
expect(result).toEqual([
|
||||
{ label: 'john', matchedUser: 'John Doe' },
|
||||
{ label: 'alice', matchedUser: 'Alice Smith' },
|
||||
{ label: 'bob', matchedUser: 'Bob Johnson' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies tags as unmatched when user not in embyUserMap', () => {
|
||||
const allTags = ['john', 'alice', 'unknown'];
|
||||
const embyUserMap = new Map([
|
||||
['john', 'John Doe'],
|
||||
['alice', 'Alice Smith']
|
||||
]);
|
||||
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
|
||||
expect(result).toEqual([
|
||||
{ label: 'john', matchedUser: 'John Doe' },
|
||||
{ label: 'alice', matchedUser: 'Alice Smith' },
|
||||
{ label: 'unknown', matchedUser: null }
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches sanitized tag names', () => {
|
||||
const allTags = ['john-example-com', 'alice-example-com'];
|
||||
const embyUserMap = new Map([
|
||||
['john-example-com', 'John Doe'],
|
||||
['alice-example-com', 'Alice Smith']
|
||||
]);
|
||||
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
|
||||
expect(result).toEqual([
|
||||
{ label: 'john-example-com', matchedUser: 'John Doe' },
|
||||
{ label: 'alice-example-com', matchedUser: 'Alice Smith' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty tags', () => {
|
||||
const embyUserMap = new Map();
|
||||
expect(TagMatcher.buildTagBadges([], embyUserMap)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles case-insensitive matching', () => {
|
||||
const allTags = ['JOHN', 'ALICE'];
|
||||
const embyUserMap = new Map([
|
||||
['john', 'John Doe'],
|
||||
['alice', 'Alice Smith']
|
||||
]);
|
||||
const result = TagMatcher.buildTagBadges(allTags, embyUserMap);
|
||||
expect(result).toEqual([
|
||||
{ label: 'JOHN', matchedUser: 'John Doe' },
|
||||
{ label: 'ALICE', matchedUser: 'Alice Smith' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+16
-9
@@ -3,8 +3,6 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
|
||||
environment: 'node',
|
||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||
globals: true,
|
||||
// Run each test file in an isolated module registry so module-level state
|
||||
@@ -12,6 +10,14 @@ export default defineConfig({
|
||||
isolate: true,
|
||||
// Give each file its own data directory so tokenStore file I/O doesn't collide
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
// Environment configuration based on test type
|
||||
environmentMatchGlobs: [
|
||||
// Server tests use node environment (must come first - more specific)
|
||||
['tests/unit/**/*.js', 'node'],
|
||||
['tests/integration/**/*.js', 'node'],
|
||||
// Frontend tests need jsdom for DOM APIs (broader pattern comes last)
|
||||
['tests/frontend/**/*.js', 'jsdom']
|
||||
],
|
||||
// Coverage via V8 (built into Node — no babel transform needed)
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
@@ -28,14 +34,15 @@ export default defineConfig({
|
||||
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||
// coverage counting varies across Node versions (CI consistently reports
|
||||
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||
// The overall numbers reflect that dashboard.js and poller.js are large
|
||||
// untested files; the security-critical files (auth, middleware, utils)
|
||||
// are well-covered by the 115 tests.
|
||||
// Thresholds updated after adding integration tests for dashboard.js,
|
||||
// emby.js, sonarr.js, radarr.js, and sabnzbd.js. The SSE /stream
|
||||
// endpoint and poller.js remain untested so thresholds are set
|
||||
// conservatively to avoid CI flap from V8 coverage variance.
|
||||
thresholds: {
|
||||
lines: 22,
|
||||
functions: 12,
|
||||
branches: 8,
|
||||
statements: 20
|
||||
lines: 55,
|
||||
functions: 55,
|
||||
branches: 40,
|
||||
statements: 55
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user