merge branch 'develop' into 'main' - Release v1.7.3

This commit is contained in:
2026-05-23 09:40:54 +01:00
55 changed files with 5969 additions and 1198 deletions
+2
View File
@@ -10,3 +10,5 @@ data/
*.db
*.db-wal
*.db-shm
.agents/
.windsurf/
+94 -5
View File
@@ -51,6 +51,7 @@ flowchart TB
dash["Dashboard Cards"]
status["Status Panel\n(Admin only)"]
history["History Tab"]
requests["Requests Tab\n+ Filters / Search"]
webhooks["Webhook Config"]
swagger["Swagger UI\n/api/swagger"]
end
@@ -61,7 +62,8 @@ flowchart TB
auth_r["Auth Routes\n/api/auth"]
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
stat_r["Status Routes\n/api/status"]
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr|ombi"]
ombi_r["Ombi Routes\n/api/ombi"]
hist_r["History Routes\n/api/history"]
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
@@ -82,12 +84,14 @@ flowchart TB
rtorrent["rTorrent"]
transmission["Transmission"]
emby["Emby / Jellyfin"]
ombi["Ombi"]
end
login -->|"POST /api/auth/login"| auth_r
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
status -->|"GET /api/status"| stat_r
history -->|"GET /api/history/recent"| hist_r
requests -->|"GET /api/ombi/requests"| ombi_r
auth_r --> tokenstore
auth_r -->|"authenticate"| emby
@@ -97,13 +101,14 @@ flowchart TB
stat_r --> cache
wh_r --> cache
wh_r --> paldra
ombi_r --> paldra
hist_r --> cache
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
poller --> pdca & paldra
poller --> cache
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
paldra -->|"HTTP/API"| sonarr & radarr
paldra -->|"HTTP/API"| sonarr & radarr & ombi
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
```
@@ -114,7 +119,7 @@ flowchart TB
Browser (SPA)
│ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie
│ GET /api/dashboard/stream → SSE stream → cache → matched downloads
│ POST /api/webhook/* ← Sonarr/Radarr push events
│ POST /api/webhook/* ← Sonarr/Radarr/Ombi push events
Express Server (:3001)
@@ -127,9 +132,12 @@ Express Server (:3001)
├── /api/auth → login, logout, me, csrf
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
│ → /config: GET endpoint for configuration status validation
├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON
├── /api/status → requireAuth → admin cache/polling/webhook status
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
├── /api/ombi → requireAuth → PALDRA → filter/sort/search → JSON
│ → /webhook/*: enable (POST), status (GET), and test (POST) endpoints
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
@@ -300,6 +308,17 @@ arrRetrieverRegistry = {
}
```
#### Ombi retriever
The `OmbiRetriever` (in `server/clients/OmbiClient.js`) fetches from:
| Task | Endpoint | Notes |
|------|----------|-------|
| Movie requests | `GET /api/v1/Request/movie` | Returns full movie request objects |
| TV requests | `GET /api/v1/Request/tv` | Returns full TV request objects |
Results are cached under `poll:ombi` and broadcast via SSE as `ombiRequests: { movie, tv }`. The client applies the same `ombiFilters.js` logic used by the server route, keeping behaviour consistent across both layers.
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
#### Retriever API Calls
@@ -363,11 +382,12 @@ Unmatched torrents are **not** included in the response (fixed in develop-refact
### 4.1 Webhook Receiver
sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event:
sofarr exposes three webhook endpoints that Sonarr, Radarr, and Ombi can be configured to call on automation and request events:
```
POST /api/webhook/sonarr
POST /api/webhook/radarr
POST /api/webhook/ombi
```
Both endpoints share identical processing logic:
@@ -451,6 +471,8 @@ The dashboard therefore receives fresh data within the round-trip time of the *a
The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL.
Similarly, the `ombi.js` route module exposes endpoints under `/api/ombi/webhook/` (including `/enable`, `/status`, and `/test`) to support one-click registration and validation of the Sofarr webhook inside the configured Ombi instance.
---
## 5. Data Flow and Real-time Updates
@@ -539,7 +561,12 @@ The browser's native `EventSource` API handles reconnection automatically on net
id: string, // Instance identifier
name: string, // Instance display name
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
}[]
}[],
ombiRequests: { // Raw Ombi movie + TV requests (client applies filters)
movie: OmbiRequest[],
tv: OmbiRequest[]
},
ombiBaseUrl: string // Ombi instance base URL for deep links
}
```
@@ -631,6 +658,67 @@ Matched download objects include `client`, `instanceId`, and `instanceName` fiel
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
### 5.5 Ombi Request Filtering
The Ombi Requests tab displays movie and TV requests from Ombi. Filtering, sorting, and text search are applied **server-side** on the REST endpoint (`GET /api/ombi/requests`) and **client-side** on every SSE update. This dual-layer approach ensures external API consumers receive pre-filtered data while the SPA remains responsive without extra round-trips.
```mermaid
sequenceDiagram
participant Client as Browser (Requests Tab)
participant SSE as SSE /api/dashboard/stream
participant Route as /api/ombi/requests
participant Filters as ombiFilters (shared)
participant PALDRA as PALDRA Registry
participant Ombi as Ombi API
Note over Client: Initial load
Client->>Route: GET /api/ombi/requests?type=…&status=…&sort=…&search=…
Route->>PALDRA: getOmbiRequests()
PALDRA->>Ombi: GET /api/v1/Request/movie + /tv
Ombi-->>PALDRA: raw request arrays
PALDRA-->>Route: { movie: [], tv: [] }
Route->>Filters: applyRequestFilters()
Filters-->>Route: filtered & sorted requests
Route-->>Client: { requests: { movie, tv }, total }
Note over Client: Real-time updates
SSE->>Client: push raw ombiRequests + ombiBaseUrl
Client->>Filters: applyRequestFilters() (same code)
Filters-->>Client: filtered & sorted requests
Client->>Client: renderRequests()
```
#### Query parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `type` | `movie` \| `tv` \| `all` | `all` | Media type filter (multi-select) |
| `status` | `pending` \| `approved` \| `available` \| `denied` | — | Request status filter (multi-select) |
| `sort` | `requestedDate_desc` \| `requestedDate_asc` \| `title_asc` \| `title_desc` | `requestedDate_desc` | Sort mode |
| `search` | string | — | Case-insensitive title substring |
| `showAll` | `'true'` \| `'false'` | `'false'` | Admin only: show all users' requests |
#### Status priority
The same `getRequestStatus()` function runs on both server and client:
1. `available` — if `available === true`
2. `denied` — if `denied === true`
3. `approved` — if `approved === true`
4. `pending` — if `requested === true`
5. `unknown` — fallback
#### Persistence
Filter and sort preferences are persisted in `localStorage` under the following keys:
| Key | Content |
|-----|---------|
| `sofarr-request-types` | `['movie', 'tv']` or subset |
| `sofarr-request-statuses` | `['pending', 'approved', 'available', 'denied']` or subset |
| `sofarr-request-sort` | `requestedDate_desc`, `requestedDate_asc`, `title_asc`, `title_desc` |
| `sofarr-request-search` | Free-text query string |
---
## 6. Caching and Smart Polling
@@ -667,6 +755,7 @@ class MemoryCache {
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
| `poll:ombi` | `{ movie: [], tv: [] }` | `POLL_INTERVAL × 3` |
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
+21
View File
@@ -6,6 +6,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
---
## [1.7.3] - 2026-05-23
### Fixed
- **Download client dropdown filter icons and type badges** — Refactored the download client selector dropdown in `client/src/ui/filters.js` to render utilizing the design system classes `.download-client-option`, `.download-client-icon`, `.download-client-option-label`, and `.download-client-type`. Options now display the client's brand SVG logo (e.g., `sabnzbd.svg`, `qbittorrent.svg` loaded from `/images/clients/`) next to the configured display name, along with a clean pill badge indicating the client type. This resolves visual ambiguity when multiple download clients share the same name (such as `"i3omb"` for SABnzbd and qBittorrent).
---
## [1.7.2] - 2026-05-22
### Fixed
- **Webhook configuration check** — Ombi webhook status now correctly checks for `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` environment variables. The webhook displays as disabled when either is missing, matching the existing *ARR webhook behavior.
- **Common webhook config endpoint** — Added `GET /api/webhook/config` endpoint that returns whether both `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` are configured, along with a list of missing items. Used by the client to determine webhook enablement status across all webhook types.
- **Client-side webhook status** — Updated `fetchWebhookStatus()` to call `/api/webhook/config` and use the result to determine if Sonarr and Radarr webhooks are enabled. Webhook status now depends on both the service-specific webhook being configured AND the Sofarr webhook config being valid.
- **Webhook config endpoint authentication** — Added `requireAuth` middleware to `GET /api/webhook/config` to enforce authentication, matching the OpenAPI contract and preventing unauthenticated information disclosure.
- **Ombi webhook enable/test config validation** — `POST /api/ombi/webhook/enable` and `POST /api/ombi/webhook/test` now validate `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` before making outbound Ombi API calls, returning `400` with descriptive errors when either is missing. Previously these routes would construct malformed URLs (`undefined/api/webhook/ombi`) if the environment variables were unset.
- **Test environment cleanup** — `tests/integration/webhook.test.js` `afterEach` now cleans up `EMBY_URL`, `SOFARR_BASE_URL`, `SONARR_INSTANCES`, and `RADARR_INSTANCES` to ensure hermetic test isolation.
---
## [1.8.0] - 2026-05-21
### Added
+19 -12
View File
@@ -320,17 +320,16 @@ OMBI_URL=https://ombi.example.com
OMBI_API_KEY=your-ombi-api-key
```
**How it works:**
- Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB)
- When a matching request is found, an Ombi icon appears in the download card
- Clicking the icon opens the Ombi request page
- If no request exists, a search link is provided instead
- Integration is fully optional - sofarr works perfectly without Ombi configured
**External ID Matching:**
- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback)
- **Movies**: TMDB ID (primary) → IMDB ID (fallback)
- Matching is performed automatically using data from Sonarr/Radarr
**Features & Architecture:**
- **Request Filtering & Search**: Retrieve, search, and filter requests in real-time by status (pending, approved, available, denied), media type (movies, TV shows), or name.
- **Dual-Layer Filtering & Persistence**: search and core filters are handled server-side for speed and efficiency, while responsive client-side refinements handle on-the-fly rendering. User filter preferences are persisted locally/session-wide to ensure a consistent experience across page refreshes.
- **Real-Time SSE Updates**: Integrates with the Server-Sent Events (SSE) push stream. When requests are created or updated, the dashboard is instantly notified without client-side polling.
- **External ID Matching**: Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB).
- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback)
- **Movies**: TMDB ID (primary) → IMDB ID (fallback)
- Matching is performed automatically using data from Sonarr/Radarr.
- **Interactive UI**: When a matching request is found, an Ombi icon appears in the download card to open the request page. If no request exists, a search link is provided instead.
- **Fully Optional**: Integration is fully optional — sofarr works perfectly without Ombi configured.
## Setting Up User Tags
@@ -445,8 +444,10 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
### 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
- `POST /api/webhook/ombi` — receive Ombi webhook events
### Webhook Management (requires auth + CSRF)
- `GET /api/webhook/config` — get webhook configuration status
- `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
@@ -455,6 +456,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
- `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
- `GET /api/ombi/webhook/status` — get Ombi webhook status and metrics
- `POST /api/ombi/webhook/enable` — one-click enable Sofarr webhook in Ombi
- `POST /api/ombi/webhook/test` — trigger an Ombi test event
### Ombi (requires auth)
- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` — SABnzbd API proxy
@@ -499,7 +506,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
npm run test:ui # interactive Vitest UI
```
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.
Over 830 tests across 39 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, webhook metrics integration, polling retrievers, and Ombi filter/search logic. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
## Development
+63 -4
View File
@@ -137,7 +137,19 @@ export async function fetchWebhookStatus() {
try {
// Fetch metrics in parallel
const metricsPromise = fetchWebhookMetrics();
// Fetch webhook configuration status (checks SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
let webhookConfigValid = false;
try {
const configRes = await fetch('/api/webhook/config');
if (configRes.ok) {
const configData = await configRes.json();
webhookConfigValid = configData.valid || false;
}
} catch (err) {
// Config endpoint not available, assume invalid
}
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
@@ -146,7 +158,7 @@ export async function fetchWebhookStatus() {
if (sonarrRes.ok) {
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
sonarrEnabled = !!sonarrSofarr;
sonarrEnabled = webhookConfigValid && !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
@@ -159,7 +171,7 @@ export async function fetchWebhookStatus() {
} catch (err) {
// Sonarr not configured
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
@@ -168,7 +180,7 @@ export async function fetchWebhookStatus() {
if (radarrRes.ok) {
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
radarrEnabled = !!radarrSofarr;
radarrEnabled = webhookConfigValid && !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
@@ -181,6 +193,22 @@ export async function fetchWebhookStatus() {
} catch (err) {
// Radarr not configured
}
// Fetch Ombi webhook status
let ombiEnabled = false;
let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
let ombiStats = null;
try {
const ombiRes = await fetch('/api/ombi/webhook/status');
if (ombiRes.ok) {
const ombiData = await ombiRes.json();
ombiEnabled = ombiData.enabled || false;
ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
ombiStats = ombiData.stats || null;
}
} catch (err) {
// Ombi not configured
}
state.webhookMetrics = await metricsPromise;
@@ -191,6 +219,7 @@ export async function fetchWebhookStatus() {
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats };
return { success: true };
} catch (err) {
@@ -279,6 +308,36 @@ export async function testRadarrWebhook() {
}
}
export async function enableOmbiWebhook() {
try {
const res = await fetch('/api/ombi/webhook/enable', {
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 Ombi webhook:', err);
return { success: false, error: err.message };
}
}
export async function testOmbiWebhook() {
try {
const res = await fetch('/api/ombi/webhook/test', {
method: 'POST',
headers: { 'X-CSRF-Token': state.csrfToken || '' }
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
return { success: true };
} catch (err) {
console.error('Failed to test Ombi webhook:', err);
return { success: false, error: err.message };
}
}
export async function refreshStatusPanel() {
try {
const res = await fetch('/api/status');
+2
View File
@@ -3,6 +3,7 @@
// Bootstrap - wire all event handlers on DOMContentLoaded
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
import { initDownloadClientFilter } from './ui/filters.js';
import { initRequestFilters } from './ui/requestFilters.js';
import { initHistoryControls } from './ui/history.js';
import { toggleStatusPanel } from './ui/statusPanel.js';
import { initWebhooks } from './ui/webhooks.js';
@@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeSwitcher();
initTabs();
initDownloadClientFilter();
initRequestFilters();
initHistoryControls();
initWebhooks();
+10
View File
@@ -25,6 +25,16 @@ export function startSSE() {
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
document.dispatchEvent(filterUpdateEvent);
}
// Store Ombi requests and base URL
if (data.ombiRequests) {
state.ombiRequests = data.ombiRequests;
// Trigger requests update event
const requestsUpdateEvent = new CustomEvent('ombiRequestsUpdated');
document.dispatchEvent(requestsUpdateEvent);
}
if (data.ombiBaseUrl) {
state.ombiBaseUrl = data.ombiBaseUrl;
}
document.getElementById('currentUser').textContent = state.currentUser || '-';
renderDownloads();
hideError();
+10 -1
View File
@@ -9,6 +9,8 @@ export const state = {
isAdmin: false,
showAll: false,
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
ombiBaseUrl: null, // Ombi base URL for generating links
ombiRequests: null, // Ombi requests data
// History section state
historyDays: 7, // Default value, will be loaded from localStorage
@@ -28,7 +30,14 @@ export const state = {
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
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
webhookMetrics: null,
// Request filter state
selectedRequestTypes: ['movie', 'tv'],
selectedRequestStatuses: [],
requestSortMode: 'requestedDate_desc',
requestSearchQuery: ''
};
// Constants
+55 -12
View File
@@ -5,9 +5,10 @@ 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');
const filterBtn = document.getElementById('download-client-dropdown-btn');
const filterDropdown = document.getElementById('download-client-dropdown');
const selectAllBtn = document.getElementById('download-client-select-all');
const deselectAllBtn = document.getElementById('download-client-deselect-all');
if (!filterBtn || !filterDropdown) return;
@@ -16,13 +17,16 @@ export function initDownloadClientFilter() {
filterDropdown.classList.toggle('open');
});
filterClose.addEventListener('click', () => {
filterDropdown.classList.remove('open');
});
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => toggleAllClients(true));
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', () => toggleAllClients(false));
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!filterDropdown.contains(e.target) && e.target !== filterBtn) {
if (!filterDropdown.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) {
filterDropdown.classList.remove('open');
}
});
@@ -35,28 +39,47 @@ export function initDownloadClientFilter() {
}
export function updateDownloadClientFilter() {
const filterList = document.getElementById('download-client-filter-list');
const filterList = document.getElementById('download-client-options');
if (!filterList) return;
filterList.innerHTML = '';
state.downloadClients.forEach((client, index) => {
const item = document.createElement('div');
item.className = 'filter-item';
item.className = 'download-client-option';
item.dataset.index = index;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'download-client-checkbox';
checkbox.id = `client-${index}`;
checkbox.checked = state.selectedDownloadClients.includes(index);
checkbox.addEventListener('change', () => toggleClientSelection(index));
const iconWrapper = document.createElement('span');
iconWrapper.className = 'download-client-icon';
const iconImg = document.createElement('img');
iconImg.src = `/images/clients/${client.type}.svg`;
iconImg.alt = `${client.name || client.type} icon`;
iconImg.onerror = () => {
iconWrapper.textContent = client.type.charAt(0).toUpperCase();
iconWrapper.classList.add('fallback');
};
iconWrapper.appendChild(iconImg);
const label = document.createElement('label');
label.className = 'download-client-option-label';
label.htmlFor = `client-${index}`;
label.textContent = client.name || `${client.type} (${client.id})`;
const typeBadge = document.createElement('span');
typeBadge.className = 'download-client-type';
typeBadge.textContent = client.type;
item.appendChild(checkbox);
item.appendChild(iconWrapper);
item.appendChild(label);
item.appendChild(typeBadge);
filterList.appendChild(item);
});
@@ -75,13 +98,33 @@ export function toggleClientSelection(index) {
renderDownloads();
}
export function toggleAllClients(select) {
if (select) {
state.selectedDownloadClients = state.downloadClients.map((_, index) => index);
} else {
state.selectedDownloadClients = [];
}
saveDownloadClients(state.selectedDownloadClients);
updateDownloadClientFilter();
renderDownloads();
}
export function updateSelectedCountDisplay() {
const countDisplay = document.getElementById('download-client-filter-count');
const countDisplay = document.getElementById('download-client-selected-text');
if (!countDisplay) return;
if (state.selectedDownloadClients.length === 0) {
countDisplay.textContent = 'All';
countDisplay.textContent = 'All clients';
} else if (state.selectedDownloadClients.length === state.downloadClients.length) {
countDisplay.textContent = 'All clients';
} else {
countDisplay.textContent = state.selectedDownloadClients.length;
const names = state.selectedDownloadClients
.map(idx => state.downloadClients[idx]?.name || state.downloadClients[idx]?.type || '')
.filter(Boolean);
if (names.length === 1) {
countDisplay.textContent = names[0];
} else {
countDisplay.textContent = `${state.selectedDownloadClients.length} clients`;
}
}
}
+227
View File
@@ -0,0 +1,227 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import {
saveRequestTypes,
saveRequestStatuses,
saveRequestSort,
saveRequestSearch
} from '../utils/storage.js';
import { renderRequests } from './requests.js';
// ---- Type filter dropdown ----
function initTypeFilter() {
const btn = document.getElementById('request-type-filter-btn');
const dropdown = document.getElementById('request-type-filter-dropdown');
const selectAll = document.getElementById('request-type-select-all');
const deselectAll = document.getElementById('request-type-deselect-all');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
selectAll?.addEventListener('click', () => setAllTypes(true));
deselectAll?.addEventListener('click', () => setAllTypes(false));
// Wire up checkboxes
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
const value = cb.closest('.request-filter-option').dataset.value;
toggleType(value, cb.checked);
});
});
updateTypeFilterUI();
}
function setAllTypes(checked) {
const dropdown = document.getElementById('request-type-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
const newTypes = [];
checkboxes.forEach(cb => {
cb.checked = checked;
if (checked) newTypes.push(cb.closest('.request-filter-option').dataset.value);
});
state.selectedRequestTypes = checked ? newTypes : [];
saveRequestTypes(state.selectedRequestTypes);
updateTypeFilterUI();
renderRequests();
}
function toggleType(value, checked) {
const idx = state.selectedRequestTypes.indexOf(value);
if (checked && idx === -1) {
state.selectedRequestTypes.push(value);
} else if (!checked && idx > -1) {
state.selectedRequestTypes.splice(idx, 1);
}
saveRequestTypes(state.selectedRequestTypes);
updateTypeFilterUI();
renderRequests();
}
function updateTypeFilterUI() {
const text = document.getElementById('request-type-selected-text');
if (!text) return;
const dropdown = document.getElementById('request-type-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
const value = cb.closest('.request-filter-option').dataset.value;
cb.checked = state.selectedRequestTypes.includes(value);
});
if (state.selectedRequestTypes.length === 0) {
text.textContent = 'All';
} else if (state.selectedRequestTypes.length === checkboxes.length) {
text.textContent = 'All';
} else {
text.textContent = state.selectedRequestTypes.length;
}
}
// ---- Status filter dropdown ----
function initStatusFilter() {
const btn = document.getElementById('request-status-filter-btn');
const dropdown = document.getElementById('request-status-filter-dropdown');
const selectAll = document.getElementById('request-status-select-all');
const deselectAll = document.getElementById('request-status-deselect-all');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.toggle('open');
});
selectAll?.addEventListener('click', () => setAllStatuses(true));
deselectAll?.addEventListener('click', () => setAllStatuses(false));
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
cb.addEventListener('change', () => {
const value = cb.closest('.request-filter-option').dataset.value;
toggleStatus(value, cb.checked);
});
});
updateStatusFilterUI();
}
function setAllStatuses(checked) {
const dropdown = document.getElementById('request-status-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
const newStatuses = [];
checkboxes.forEach(cb => {
cb.checked = checked;
if (checked) newStatuses.push(cb.closest('.request-filter-option').dataset.value);
});
state.selectedRequestStatuses = checked ? newStatuses : [];
saveRequestStatuses(state.selectedRequestStatuses);
updateStatusFilterUI();
renderRequests();
}
function toggleStatus(value, checked) {
const idx = state.selectedRequestStatuses.indexOf(value);
if (checked && idx === -1) {
state.selectedRequestStatuses.push(value);
} else if (!checked && idx > -1) {
state.selectedRequestStatuses.splice(idx, 1);
}
saveRequestStatuses(state.selectedRequestStatuses);
updateStatusFilterUI();
renderRequests();
}
function updateStatusFilterUI() {
const text = document.getElementById('request-status-selected-text');
if (!text) return;
const dropdown = document.getElementById('request-status-filter-dropdown');
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
checkboxes.forEach(cb => {
const value = cb.closest('.request-filter-option').dataset.value;
cb.checked = state.selectedRequestStatuses.includes(value);
});
if (state.selectedRequestStatuses.length === 0) {
text.textContent = 'All';
} else if (state.selectedRequestStatuses.length === checkboxes.length) {
text.textContent = 'All';
} else {
text.textContent = state.selectedRequestStatuses.length;
}
}
// ---- Sort select ----
function initSortSelect() {
const select = document.getElementById('request-sort-select');
if (!select) return;
select.value = state.requestSortMode;
select.addEventListener('change', (e) => {
state.requestSortMode = e.target.value;
saveRequestSort(state.requestSortMode);
renderRequests();
});
}
// ---- Search input ----
function initSearchInput() {
const input = document.getElementById('request-search-input');
if (!input) return;
input.value = state.requestSearchQuery;
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
state.requestSearchQuery = e.target.value;
saveRequestSearch(state.requestSearchQuery);
renderRequests();
}, 200);
});
}
// ---- Global click-outside handler ----
function initClickOutside() {
document.addEventListener('click', (e) => {
const typeDropdown = document.getElementById('request-type-filter-dropdown');
const typeBtn = document.getElementById('request-type-filter-btn');
const statusDropdown = document.getElementById('request-status-filter-dropdown');
const statusBtn = document.getElementById('request-status-filter-btn');
if (typeDropdown && !typeDropdown.contains(e.target) && e.target !== typeBtn && !typeBtn?.contains(e.target)) {
typeDropdown.classList.remove('open');
}
if (statusDropdown && !statusDropdown.contains(e.target) && e.target !== statusBtn && !statusBtn?.contains(e.target)) {
statusDropdown.classList.remove('open');
}
});
}
// ---- Public API ----
export function initRequestFilters() {
initTypeFilter();
initStatusFilter();
initSortSelect();
initSearchInput();
initClickOutside();
// Listen for SSE updates (registered once on app bootstrap)
document.addEventListener('ombiRequestsUpdated', () => {
renderRequests();
});
}
+175
View File
@@ -0,0 +1,175 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { state } from '../state.js';
import { escapeHtml } from '../utils/format.js';
import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
/**
* Helper function to extract the username from an Ombi request object.
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
* not a string, so we need to extract the username from the object.
*
* Must stay in sync with server/utils/ombiHelpers.js
*
* @param {Object} request - The Ombi request object
* @returns {string} The extracted username, or empty string if not found
*/
function extractRequestedUser(request) {
if (!request) return '';
// Handle object format: OmbiStore.Entities.OmbiUser
if (request.requestedUser && typeof request.requestedUser === 'object') {
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
return request.requestedUser.alias ||
request.requestedUser.userAlias ||
request.requestedUser.userName ||
request.requestedUser.normalizedUserName ||
request.requestedByAlias || '';
}
// Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || '';
}
export function renderRequests() {
const requestsList = document.getElementById('requests-list');
const noRequests = document.getElementById('no-requests');
if (!requestsList) return;
const ombiRequests = state.ombiRequests || { movie: [], tv: [] };
const allRequests = [
...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })),
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
];
// Apply client-side filters, sorting, and search
const filtered = applyRequestFilters(allRequests, {
types: state.selectedRequestTypes,
statuses: state.selectedRequestStatuses,
sort: state.requestSortMode,
search: state.requestSearchQuery
});
requestsList.innerHTML = '';
if (filtered.length === 0) {
if (noRequests) {
noRequests.style.display = 'block';
const p = noRequests.querySelector('p');
if (p) {
// Differentiate between no data from Ombi vs filters excluded everything
const hasAnyData = allRequests.length > 0;
p.textContent = hasAnyData
? 'No requests match your filters.'
: 'No requests found.';
}
}
return;
}
if (noRequests) noRequests.style.display = 'none';
filtered.forEach(request => {
const card = createRequestCard(request);
requestsList.appendChild(card);
});
}
function createRequestCard(request) {
if (!request) {
const card = document.createElement('div');
card.className = 'request-card';
card.textContent = 'Invalid request data';
return card;
}
const card = document.createElement('div');
card.className = 'request-card';
const typeIcon = document.createElement('span');
typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺';
const content = document.createElement('div');
content.className = 'request-content';
const title = document.createElement('div');
title.className = 'request-title';
title.textContent = request.title || 'Unknown Title';
const meta = document.createElement('div');
meta.className = 'request-meta';
const statusBadge = createStatusBadge(request);
meta.appendChild(statusBadge);
if (request.year) {
const year = document.createElement('span');
year.className = 'request-year';
year.textContent = request.year;
meta.appendChild(year);
}
const username = extractRequestedUser(request);
if (username) {
const user = document.createElement('span');
user.className = 'request-user';
user.textContent = `Requested by: ${username}`;
meta.appendChild(user);
}
if (request.quality) {
const quality = document.createElement('span');
quality.className = 'request-quality';
quality.textContent = request.quality;
meta.appendChild(quality);
}
content.appendChild(title);
content.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'request-actions';
if (state.ombiBaseUrl && request.theMovieDbId) {
const ombiLink = document.createElement('a');
ombiLink.className = 'request-link ombi-link';
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
ombiLink.target = '_blank';
ombiLink.title = 'View in Ombi';
const ombiIcon = document.createElement('img');
ombiIcon.src = '/images/ombi.svg';
ombiIcon.alt = 'Ombi';
ombiIcon.className = 'request-icon';
ombiLink.appendChild(ombiIcon);
actions.appendChild(ombiLink);
}
card.appendChild(typeIcon);
card.appendChild(content);
card.appendChild(actions);
return card;
}
function createStatusBadge(request) {
const badge = document.createElement('span');
badge.className = 'request-status-badge';
const status = getRequestStatus(request);
const statusTexts = {
available: 'Available',
denied: `Denied: ${request.deniedReason || 'No reason'}`,
approved: 'Approved',
pending: 'Pending',
unknown: 'Unknown'
};
badge.classList.add(status);
badge.textContent = statusTexts[status] || 'Unknown';
return badge;
}
+29 -9
View File
@@ -2,42 +2,62 @@
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
import { loadHistory } from './history.js';
import { renderRequests } from './requests.js';
export function initTabs() {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const requestsTab = document.querySelector('[data-tab="requests"]');
const historyTab = document.querySelector('[data-tab="history"]');
if (!downloadsTab || !historyTab) return;
// Load saved tab
const savedTab = getActiveTab();
if (savedTab === 'history') {
if (savedTab === 'requests') {
activateTab('requests');
} else if (savedTab === 'history') {
activateTab('history');
} else {
activateTab('downloads');
}
downloadsTab.addEventListener('click', () => activateTab('downloads'));
if (requestsTab) {
requestsTab.addEventListener('click', () => activateTab('requests'));
}
historyTab.addEventListener('click', () => activateTab('history'));
}
export function activateTab(tab) {
const downloadsTab = document.querySelector('[data-tab="downloads"]');
const requestsTab = document.querySelector('[data-tab="requests"]');
const historyTab = document.querySelector('[data-tab="history"]');
const downloadsSection = document.getElementById('tab-downloads');
const requestsSection = document.getElementById('tab-requests');
const historySection = document.getElementById('tab-history');
// Remove active class from all tabs
if (downloadsTab) downloadsTab.classList.remove('active');
if (requestsTab) requestsTab.classList.remove('active');
if (historyTab) historyTab.classList.remove('active');
// Hide all sections
if (downloadsSection) downloadsSection.classList.add('hidden');
if (requestsSection) requestsSection.classList.add('hidden');
if (historySection) historySection.classList.add('hidden');
if (tab === 'downloads') {
downloadsTab.classList.add('active');
historyTab.classList.remove('active');
downloadsSection.classList.remove('hidden');
historySection.classList.add('hidden');
if (downloadsTab) downloadsTab.classList.add('active');
if (downloadsSection) downloadsSection.classList.remove('hidden');
saveActiveTab('downloads');
} else if (tab === 'requests') {
if (requestsTab) requestsTab.classList.add('active');
if (requestsSection) requestsSection.classList.remove('hidden');
saveActiveTab('requests');
renderRequests();
} else if (tab === 'history') {
historyTab.classList.add('active');
downloadsTab.classList.remove('active');
historySection.classList.remove('hidden');
downloadsSection.classList.add('hidden');
if (historyTab) historyTab.classList.add('active');
if (historySection) historySection.classList.remove('hidden');
saveActiveTab('history');
loadHistory();
}
+112 -33
View File
@@ -1,7 +1,7 @@
// 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 { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, enableOmbiWebhook as apiEnableOmbiWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../api.js';
import { formatTimeAgo } from '../utils/format.js';
export function initWebhooks() {
@@ -13,8 +13,10 @@ export function initWebhooks() {
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('enable-ombi-webhook').addEventListener('click', enableOmbiWebhook);
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
document.getElementById('test-ombi-webhook').addEventListener('click', testOmbiWebhook);
}
export function toggleWebhookSection() {
@@ -58,9 +60,9 @@ export function renderWebhookStatus() {
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) {
sonarrStatus.textContent = state.sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
sonarrStatus.className = 'status-indicator ' + (state.sonarrWebhook.enabled ? 'enabled' : 'disabled');
if (state.sonarrWebhook.enabled) {
sonarrEnableBtn.classList.add('hidden');
sonarrTestBtn.classList.remove('hidden');
sonarrTriggers.classList.remove('hidden');
@@ -70,22 +72,22 @@ export function renderWebhookStatus() {
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 (state.sonarrWebhook.enabled) {
document.getElementById('sonarr-onGrab').textContent = state.sonarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('sonarr-onDownload').textContent = state.sonarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('sonarr-onImport').textContent = state.sonarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('sonarr-onUpgrade').textContent = state.sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (sonarrWebhook.stats) {
if (state.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);
document.getElementById('sonarr-events').textContent = state.sonarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('sonarr-polls').textContent = state.sonarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('sonarr-last').textContent = formatTimeAgo(state.sonarrWebhook.stats.lastWebhookTimestamp);
} else {
sonarrStats.classList.add('hidden');
}
@@ -97,9 +99,9 @@ export function renderWebhookStatus() {
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) {
radarrStatus.textContent = state.radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
radarrStatus.className = 'status-indicator ' + (state.radarrWebhook.enabled ? 'enabled' : 'disabled');
if (state.radarrWebhook.enabled) {
radarrEnableBtn.classList.add('hidden');
radarrTestBtn.classList.remove('hidden');
radarrTriggers.classList.remove('hidden');
@@ -109,25 +111,66 @@ export function renderWebhookStatus() {
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 (state.radarrWebhook.enabled) {
document.getElementById('radarr-onGrab').textContent = state.radarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (state.radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('radarr-onDownload').textContent = state.radarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (state.radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('radarr-onImport').textContent = state.radarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('radarr-onImport').className = 'trigger-value ' + (state.radarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('radarr-onUpgrade').textContent = state.radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (state.radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (radarrWebhook.stats) {
if (state.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);
document.getElementById('radarr-events').textContent = state.radarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('radarr-polls').textContent = state.radarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('radarr-last').textContent = formatTimeAgo(state.radarrWebhook.stats.lastWebhookTimestamp);
} else {
radarrStats.classList.add('hidden');
}
// Ombi
const ombiStatus = document.getElementById('ombi-status');
const ombiEnableBtn = document.getElementById('enable-ombi-webhook');
const ombiTestBtn = document.getElementById('test-ombi-webhook');
const ombiTriggers = document.getElementById('ombi-triggers');
const ombiStats = document.getElementById('ombi-stats');
ombiStatus.textContent = state.ombiWebhook.enabled ? '● Enabled' : '○ Disabled';
ombiStatus.className = 'status-indicator ' + (state.ombiWebhook.enabled ? 'enabled' : 'disabled');
if (state.ombiWebhook.enabled) {
ombiEnableBtn.classList.add('hidden');
ombiTestBtn.classList.remove('hidden');
ombiTriggers.classList.remove('hidden');
} else {
ombiEnableBtn.classList.remove('hidden');
ombiTestBtn.classList.add('hidden');
ombiTriggers.classList.add('hidden');
}
if (state.ombiWebhook.enabled) {
document.getElementById('ombi-requestAvailable').textContent = state.ombiWebhook.triggers.requestAvailable ? '✓' : '✗';
document.getElementById('ombi-requestAvailable').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestAvailable ? 'active' : 'inactive');
document.getElementById('ombi-requestApproved').textContent = state.ombiWebhook.triggers.requestApproved ? '✓' : '✗';
document.getElementById('ombi-requestApproved').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestApproved ? 'active' : 'inactive');
document.getElementById('ombi-requestDeclined').textContent = state.ombiWebhook.triggers.requestDeclined ? '✓' : '✗';
document.getElementById('ombi-requestDeclined').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestDeclined ? 'active' : 'inactive');
document.getElementById('ombi-requestPending').textContent = state.ombiWebhook.triggers.requestPending ? '✓' : '✗';
document.getElementById('ombi-requestPending').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestPending ? 'active' : 'inactive');
document.getElementById('ombi-requestProcessing').textContent = state.ombiWebhook.triggers.requestProcessing ? '✓' : '✗';
document.getElementById('ombi-requestProcessing').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestProcessing ? 'active' : 'inactive');
}
if (state.ombiWebhook.stats) {
ombiStats.classList.remove('hidden');
document.getElementById('ombi-events').textContent = state.ombiWebhook.stats.eventsReceived ?? 0;
document.getElementById('ombi-polls').textContent = state.ombiWebhook.stats.pollsSkipped ?? 0;
document.getElementById('ombi-last').textContent = formatTimeAgo(state.ombiWebhook.stats.lastWebhookTimestamp);
} else {
ombiStats.classList.add('hidden');
}
}
export async function enableSonarrWebhook() {
@@ -198,12 +241,48 @@ export async function testRadarrWebhook() {
}
}
export async function enableOmbiWebhook() {
setWebhookLoading(true);
try {
const result = await apiEnableOmbiWebhook();
if (!result.success) {
console.error('Failed to enable Ombi webhook:', result.error);
alert('Failed to enable Ombi webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to enable Ombi webhook:', err);
alert('Failed to enable Ombi webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
export async function testOmbiWebhook() {
setWebhookLoading(true);
try {
const result = await apiTestOmbiWebhook();
if (result.success) {
alert('Ombi webhook test sent successfully!');
} else {
console.error('Failed to test Ombi webhook:', result.error);
alert('Failed to test Ombi webhook. Check console for details.');
}
} catch (err) {
console.error('Failed to test Ombi webhook:', err);
alert('Failed to test Ombi 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('enable-ombi-webhook').disabled = loading;
document.getElementById('test-sonarr-webhook').disabled = loading;
document.getElementById('test-radarr-webhook').disabled = loading;
document.getElementById('test-ombi-webhook').disabled = loading;
const loadingEl = document.getElementById('webhook-loading');
if (loading) {
loadingEl.classList.remove('hidden');
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Pure filter / sort / search utilities for Ombi requests.
* Must stay in sync with server/utils/ombiFilters.js
*/
/**
* Derive a single status string from an Ombi request object.
* Priority: available > denied > approved > pending > unknown
*
* @param {Object} request
* @returns {string}
*/
export function getRequestStatus(request) {
if (!request) return 'unknown';
if (request.available) return 'available';
if (request.denied) return 'denied';
if (request.approved) return 'approved';
if (request.requested) return 'pending';
return 'unknown';
}
/**
* Filter requests by media type.
*
* @param {Array} requests
* @param {string[]} types
* @returns {Array}
*/
export function filterByType(requests, types) {
if (!types || types.length === 0) return requests;
const normalized = types.map(t => t.toLowerCase());
if (normalized.includes('all')) return requests;
return requests.filter(r => normalized.includes(r.mediaType));
}
/**
* Filter requests by status.
*
* @param {Array} requests
* @param {string[]} statuses
* @returns {Array}
*/
export function filterByStatus(requests, statuses) {
if (!statuses || statuses.length === 0) return requests;
const normalized = statuses.map(s => s.toLowerCase());
return requests.filter(r => normalized.includes(getRequestStatus(r)));
}
/**
* Filter requests by case-insensitive title substring.
*
* @param {Array} requests
* @param {string} query
* @returns {Array}
*/
export function filterBySearch(requests, query) {
if (!query || query.trim() === '') return requests;
const q = query.trim().toLowerCase();
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
}
/**
* Sort requests by the given sort mode.
*
* @param {Array} requests
* @param {string} sortMode
* @returns {Array}
*/
export function sortRequests(requests, sortMode) {
const sorted = [...requests];
switch (sortMode) {
case 'requestedDate_asc':
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return da - db;
});
case 'title_asc':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'title_desc':
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
case 'requestedDate_desc':
default:
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return db - da;
});
}
}
/**
* Apply all filters and sorting in one call.
*
* @param {Array} requests
* @param {Object} options
* @returns {Array}
*/
export function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
let result = [...requests];
result = filterByType(result, types);
result = filterByStatus(result, statuses);
result = filterBySearch(result, search);
result = sortRequests(result, sort);
return result;
}
+51
View File
@@ -46,6 +46,41 @@ import { state } from '../state.js';
}
})();
// Load request filter preferences from localStorage
(function loadRequestFilters() {
try {
const savedTypes = localStorage.getItem('sofarr-request-types');
if (savedTypes) state.selectedRequestTypes = JSON.parse(savedTypes);
} catch (e) {
console.error('[Storage] Failed to load request types:', e);
state.selectedRequestTypes = ['movie', 'tv'];
}
try {
const savedStatuses = localStorage.getItem('sofarr-request-statuses');
if (savedStatuses) state.selectedRequestStatuses = JSON.parse(savedStatuses);
} catch (e) {
console.error('[Storage] Failed to load request statuses:', e);
state.selectedRequestStatuses = [];
}
try {
const savedSort = localStorage.getItem('sofarr-request-sort');
if (savedSort) state.requestSortMode = savedSort;
} catch (e) {
console.error('[Storage] Failed to load request sort:', e);
state.requestSortMode = 'requestedDate_desc';
}
try {
const savedSearch = localStorage.getItem('sofarr-request-search');
if (savedSearch !== null) state.requestSearchQuery = savedSearch;
} catch (e) {
console.error('[Storage] Failed to load request search:', e);
state.requestSearchQuery = '';
}
})();
// Export helper functions for localStorage operations
export function saveHistoryDays(days) {
localStorage.setItem('sofarr-history-days', days);
@@ -74,3 +109,19 @@ export function getActiveTab() {
export function saveActiveTab(tab) {
localStorage.setItem('sofarr-active-tab', tab);
}
export function saveRequestTypes(types) {
localStorage.setItem('sofarr-request-types', JSON.stringify(types));
}
export function saveRequestStatuses(statuses) {
localStorage.setItem('sofarr-request-statuses', JSON.stringify(statuses));
}
export function saveRequestSort(sort) {
localStorage.setItem('sofarr-request-sort', sort);
}
export function saveRequestSearch(query) {
localStorage.setItem('sofarr-request-search', query);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "1.7.0",
"version": "1.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.0",
"version": "1.7.3",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.7.1",
"version": "1.7.3",
"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": {
+20 -20
View File
File diff suppressed because one or more lines are too long
+112
View File
@@ -129,6 +129,31 @@
</div>
</div>
</div>
<!-- Ombi Webhook -->
<div class="webhook-instance">
<h3>Ombi</h3>
<div class="webhook-status">
<span class="status-indicator" id="ombi-status">○ Disabled</span>
<button id="enable-ombi-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
<button id="test-ombi-webhook" class="test-webhook-btn hidden">Test</button>
</div>
<div class="webhook-triggers hidden" id="ombi-triggers">
<div class="trigger-item"><span class="trigger-label">Request Available</span><span class="trigger-value" id="ombi-requestAvailable"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Approved</span><span class="trigger-value" id="ombi-requestApproved"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Declined</span><span class="trigger-value" id="ombi-requestDeclined"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Pending</span><span class="trigger-value" id="ombi-requestPending"></span></div>
<div class="trigger-item"><span class="trigger-label">Request Processing</span><span class="trigger-value" id="ombi-requestProcessing"></span></div>
</div>
<div class="webhook-stats hidden" id="ombi-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="ombi-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="ombi-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="ombi-last">Never</span></div>
</div>
</div>
</div>
</div>
</div>
@@ -139,6 +164,7 @@
<div class="main-tabs">
<div class="tab-bar">
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
<button class="tab-btn" data-tab="requests">Requests</button>
<button class="tab-btn" data-tab="history">Recently Completed</button>
</div>
@@ -172,6 +198,92 @@
</div>
</div>
<div class="tab-panel hidden" id="tab-requests">
<div class="requests-container">
<div class="requests-header">
<div class="requests-controls">
<!-- Media Type Filter -->
<div class="request-filter" id="request-type-filter">
<label class="request-filter-label">Type:</label>
<button class="request-filter-btn" id="request-type-filter-btn" type="button" aria-expanded="false">
<span id="request-type-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
<div class="request-filter-dropdown-header">
<button class="request-filter-dropdown-btn-small" id="request-type-select-all" type="button">Select All</button>
<button class="request-filter-dropdown-btn-small" id="request-type-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-type-options">
<div class="request-filter-option" data-value="movie">
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
<label for="request-type-movie">Movies</label>
</div>
<div class="request-filter-option" data-value="tv">
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
<label for="request-type-tv">TV Shows</label>
</div>
</div>
</div>
</div>
<!-- Status Filter -->
<div class="request-filter" id="request-status-filter">
<label class="request-filter-label">Status:</label>
<button class="request-filter-btn" id="request-status-filter-btn" type="button" aria-expanded="false">
<span id="request-status-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
<div class="request-filter-dropdown-header">
<button class="request-filter-dropdown-btn-small" id="request-status-select-all" type="button">Select All</button>
<button class="request-filter-dropdown-btn-small" id="request-status-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-status-options">
<div class="request-filter-option" data-value="pending">
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
<label for="request-status-pending">Pending</label>
</div>
<div class="request-filter-option" data-value="approved">
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
<label for="request-status-approved">Approved</label>
</div>
<div class="request-filter-option" data-value="available">
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
<label for="request-status-available">Available</label>
</div>
<div class="request-filter-option" data-value="denied">
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
<label for="request-status-denied">Denied</label>
</div>
</div>
</div>
</div>
<!-- Sort Dropdown -->
<div class="request-sort">
<label class="request-filter-label" for="request-sort-select">Sort:</label>
<select id="request-sort-select" class="request-sort-select">
<option value="requestedDate_desc">Newest to oldest</option>
<option value="requestedDate_asc">Oldest to newest</option>
<option value="title_asc">AZ</option>
<option value="title_desc">ZA</option>
</select>
</div>
<!-- Search Input -->
<div class="request-search">
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
</div>
</div>
</div>
<div id="no-requests" class="no-requests hidden">
<p>No requests found.</p>
</div>
<div id="requests-list" class="requests-list"></div>
</div>
</div>
<div class="tab-panel hidden" id="tab-history">
<div class="history-container" id="history-container">
<div class="history-header">
+334
View File
@@ -895,6 +895,186 @@ body {
color: var(--text-primary);
}
/* ===== Request Filters ===== */
.requests-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.requests-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.request-filter {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.request-filter-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.request-filter-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: 100px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background 0.15s, border-color 0.15s;
}
.request-filter-btn:hover {
background: var(--surface-alt);
}
.request-filter-btn:focus {
outline: none;
border-color: var(--accent);
}
.request-filter-btn .dropdown-arrow {
font-size: 0.75rem;
transition: transform 0.2s;
}
.request-filter-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: 180px;
max-width: 280px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.request-filter-dropdown.open {
display: block;
}
.request-filter-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;
}
.request-filter-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;
}
.request-filter-dropdown-btn-small:hover {
background: var(--accent-light);
color: var(--text-primary);
}
.request-filter-options {
padding: 4px 0;
}
.request-filter-option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background 0.15s;
}
.request-filter-option:hover {
background: var(--surface-alt);
}
.request-filter-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.request-filter-option label {
flex: 1;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
}
.request-sort {
display: inline-flex;
align-items: center;
gap: 6px;
}
.request-sort-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;
}
.request-sort-select:focus {
outline: none;
border-color: var(--accent);
}
.request-search {
display: inline-flex;
align-items: center;
}
.request-search-input {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
min-width: 160px;
}
.request-search-input:focus {
outline: none;
border-color: var(--accent);
}
.history-header {
display: flex;
align-items: center;
@@ -2029,3 +2209,157 @@ body {
font-size: 0.9rem;
font-weight: 600;
}
/* ===== Requests Tab ===== */
.requests-container {
padding: 20px;
}
.requests-header {
margin-bottom: 20px;
}
.requests-header h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
}
.no-requests {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.requests-list {
display: grid;
gap: 12px;
}
.request-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.request-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--accent);
}
.request-type-icon {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--surface-alt);
border-radius: 8px;
flex-shrink: 0;
}
.request-content {
flex: 1;
min-width: 0;
}
.request-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.request-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-secondary);
}
.request-status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.request-status-badge.available {
background: var(--success-bg);
color: var(--success);
}
.request-status-badge.approved {
background: var(--info-bg);
color: var(--info);
}
.request-status-badge.denied {
background: var(--danger-bg);
color: var(--danger);
}
.request-status-badge.pending {
background: var(--surface-alt);
color: var(--text-muted);
}
.request-status-badge.unknown {
background: var(--surface-alt);
color: var(--text-muted);
}
.request-year {
color: var(--text-muted);
}
.request-user {
color: var(--text-secondary);
}
.request-quality {
background: var(--accent-light);
color: var(--accent);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.request-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.request-link {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: var(--surface-alt);
border-radius: 6px;
transition: background 0.2s ease;
}
.request-link:hover {
background: var(--accent-light);
}
.request-icon {
height: 20px;
width: 20px;
}
+2
View File
@@ -25,6 +25,7 @@ const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
@@ -218,6 +219,7 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
+20 -3
View File
@@ -45,15 +45,32 @@ class SABnzbdClient extends DownloadClient {
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse, clientStatus] = await Promise.all([
const [queueResponse, historyResponse] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 }),
this.getClientStatus()
this.makeRequest({ mode: 'history', limit: 10 })
]);
const queueData = queueResponse.data;
const historyData = historyResponse.data;
// Extract client status directly from the fetched queue data instead of making a redundant HTTP request
let clientStatus = null;
if (queueData && queueData.queue) {
const q = queueData.queue;
clientStatus = {
status: q.status,
speed: q.speed,
kbpersec: q.kbpersec,
sizeleft: q.sizeleft,
mbleft: q.mbleft,
mb: q.mb,
diskspace1: q.diskspace1,
diskspace2: q.diskspace2,
loadavg: q.loadavg,
pause_int: q.pause_int
};
}
const downloads = [];
// Process active queue items
+5 -4
View File
@@ -26,13 +26,14 @@ function verifyCsrf(req, res, next) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
if (cookieToken.length !== headerToken.length) {
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
// Constant-time comparison of underlying buffer lengths to prevent timing attacks
if (a.length !== b.length) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
const a = Buffer.from(cookieToken);
const b = Buffer.from(headerToken);
if (!require('crypto').timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
+321 -4
View File
@@ -218,13 +218,64 @@ components:
description: Additional error details (dev-only)
example: "Emby API returned 401"
OmbiRequest:
type: object
description: Normalised Ombi movie or TV request
properties:
id:
type: integer
example: 123
title:
type: string
example: "The Batman"
requestedDate:
type: string
format: date-time
nullable: true
example: "2026-05-21T10:00:00.000Z"
available:
type: boolean
example: false
approved:
type: boolean
example: true
denied:
type: boolean
example: false
requested:
type: boolean
example: true
deniedReason:
type: string
nullable: true
example: "Already on Plex"
theMovieDbId:
type: integer
nullable: true
example: 414906
year:
type: integer
nullable: true
example: 2022
quality:
type: string
nullable: true
example: "1080p"
requestedUser:
type: object
nullable: true
mediaType:
type: string
enum: [movie, tv]
description: Injected by Sofarr to distinguish movies from TV
example: "movie"
BlocklistSearchRequest:
type: object
required:
- arrQueueId
- arrType
- arrInstanceUrl
- arrInstanceKey
- arrContentId
- arrContentType
properties:
@@ -244,7 +295,7 @@ components:
example: "http://sonarr:8989"
arrInstanceKey:
type: string
description: API key for the *arr instance
description: API key for the *arr instance. Only required for admin users; non-admin requests resolve the key from server-side configurations using arrInstanceUrl.
example: "abc123def456"
arrContentId:
type: integer
@@ -634,7 +685,7 @@ paths:
post:
tags: [Dashboard]
summary: Blocklist and re-search
description: Admin-only. Removes queue item with blocklist=true, then triggers new automatic search.
description: Removes queue item with blocklist=true, then triggers new automatic search. Accessible by admins, or by non-admins who own the item under specific eligibility conditions (has import issues, or torrent older than 1h and availability < 100%).
security:
- CookieAuth: []
- CsrfToken: []
@@ -656,7 +707,7 @@ paths:
type: boolean
example: true
'403':
description: Admin access required
description: Permission denied (admin or qualifying conditions required)
content:
application/json:
schema:
@@ -801,6 +852,72 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/webhook/ombi:
post:
tags: [Webhook]
summary: Ombi webhook
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Event received
content:
application/json:
schema:
type: object
properties:
received:
type: boolean
example: true
'401':
description: Invalid or missing secret
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'400':
description: Invalid payload
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/webhook/config:
get:
tags: [Webhook]
summary: Get webhook configuration status
description: Returns whether the required webhook configuration is properly configured.
security:
- CookieAuth: []
responses:
'200':
description: Webhook configuration status
content:
application/json:
schema:
type: object
properties:
valid:
type: boolean
example: true
missing:
type: array
items:
type: string
example: []
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
# Sonarr proxy endpoints (detailed in JSDoc)
/api/sonarr/queue:
get:
@@ -1426,3 +1543,203 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
# Ombi endpoints
/api/ombi/requests:
get:
tags: [Ombi]
summary: Get Ombi requests
description: Returns Ombi movie and TV requests. Non-admin users only see their own requests, while admins see all requests. Supports server-side filtering by media type, request status, title search, and sorting.
security:
- CookieAuth: []
parameters:
- name: type
in: query
schema:
type: array
items:
type: string
enum: [movie, tv, all]
default: [all]
description: Filter by media type. Omit or use `all` for both.
style: form
explode: true
- name: status
in: query
schema:
type: array
items:
type: string
enum: [pending, approved, available, denied]
description: Filter by request status. Omit for all statuses.
style: form
explode: true
- name: sort
in: query
schema:
type: string
enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc]
default: requestedDate_desc
description: Sort mode.
- name: search
in: query
schema:
type: string
description: Case-insensitive substring match on title.
- name: showAll
in: query
schema:
type: string
enum: ['true', 'false']
description: Admin only. Show all users' requests.
responses:
'200':
description: Ombi requests retrieved successfully
content:
application/json:
schema:
type: object
properties:
user:
type: string
isAdmin:
type: boolean
showAll:
type: boolean
requests:
type: object
properties:
movie:
type: array
items:
$ref: '#/components/schemas/OmbiRequest'
tv:
type: array
items:
$ref: '#/components/schemas/OmbiRequest'
total:
type: integer
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/ombi/webhook/enable:
post:
tags: [Ombi]
summary: Enable Ombi webhook
description: Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
security:
- CookieAuth: []
responses:
'200':
description: Webhook enabled successfully
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
webhookUrl:
type: string
applicationToken:
type: string
'400':
description: Invalid request or missing configuration
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/ombi/webhook/status:
get:
tags: [Ombi]
summary: Get Ombi webhook status
description: Returns the current Ombi webhook configuration status and metrics.
security:
- CookieAuth: []
responses:
'200':
description: Webhook status retrieved successfully
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
webhookUrl:
type: string
nullable: true
applicationToken:
type: string
nullable: true
triggers:
type: object
properties:
requestAvailable:
type: boolean
requestApproved:
type: boolean
requestDeclined:
type: boolean
requestPending:
type: boolean
requestProcessing:
type: boolean
stats:
type: object
nullable: true
properties:
eventsReceived:
type: integer
pollsSkipped:
type: integer
lastWebhookTimestamp:
type: integer
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/ombi/webhook/test:
post:
tags: [Ombi]
summary: Test Ombi webhook
description: Sends a test webhook event to the Sofarr Ombi webhook endpoint.
security:
- CookieAuth: []
- CsrfToken: []
responses:
'200':
description: Test webhook sent successfully
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
'400':
description: Invalid request or missing configuration
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+71 -22
View File
@@ -12,7 +12,9 @@ const TagMatcher = require('../services/TagMatcher');
const { buildUserDownloads } = require('../services/DownloadBuilder');
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { getOmbiInstances } = require('../utils/config');
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { canBlocklist } = require('../services/DownloadAssembler');
// Track active SSE clients for disconnect cleanup
@@ -29,6 +31,7 @@ function readCacheSnapshot() {
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
const ombiRequests = cache.get('poll:ombi-requests') || { movie: [], tv: [] };
return {
sabnzbdQueue: { data: { queue: sabQueueData } },
@@ -39,7 +42,8 @@ function readCacheSnapshot() {
radarrHistory: { data: radarrHistoryData },
radarrTags: { data: radarrTagsData },
qbittorrentTorrents,
sonarrTagsResults
sonarrTagsResults,
ombiRequests
};
}
@@ -489,7 +493,21 @@ router.get('/stream', requireAuth, async (req, res) => {
name: c.name,
type: c.getClientType()
}));
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\n\n`);
// Filter Ombi requests by user if not admin or if showAll is false
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
const ombiRequestsFiltered = {
movie: filteredOmbiMovieRequests,
tv: filteredOmbiTvRequests
};
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients, ombiRequests: ombiRequestsFiltered, ombiBaseUrl })}\n\n`);
} catch (err) {
console.error('[SSE] Error building payload:', sanitizeError(err));
}
@@ -498,6 +516,12 @@ router.get('/stream', requireAuth, async (req, res) => {
// Send initial data immediately
await sendDownloads();
// For testing purposes, allow closing the stream gracefully after initial payload
if (req.query.testClose === 'true') {
res.end();
return;
}
// Subscribe to poll-complete notifications
onPollComplete(sendDownloads);
@@ -534,15 +558,18 @@ router.get('/stream', requireAuth, async (req, res) => {
* tags: [Dashboard]
* summary: Blocklist and re-search
* description: |
* Admin-only endpoint that removes a queue item from Sonarr/Radarr with blocklist=true
* (so the release is not grabbed again), then immediately triggers a new automatic search
* for the same episode/movie.
* Removes a queue item from Sonarr/Radarr with blocklist=true (so the release is not grabbed again),
* then immediately triggers a new automatic search for the same episode/movie.
*
* **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header.
* Accessible by admins, or by non-admins who own the item under specific qualifying eligibility conditions:
* - The download has import issues OR
* - The torrent is older than 1 hour and has availability below 100%
*
* **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header.
*
* **Workflow:**
* 1. Validate user is admin
* 2. Validate all required fields are present
* 1. Validate user and required fields
* 2. Check blocklist eligibility (admin status or non-admin qualifying criteria)
* 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true`
* 4. Trigger automatic search command:
* - Sonarr: EpisodeSearch with episodeIds
@@ -553,18 +580,17 @@ router.get('/stream', requireAuth, async (req, res) => {
* - `arrQueueId`: Sonarr/Radarr queue record ID
* - `arrType`: Must be "sonarr" or "radarr"
* - `arrInstanceUrl`: Base URL of the *arr instance
* - `arrInstanceKey`: API key for the *arr instance
* - `arrInstanceKey`: API key for the *arr instance (only required for admins; non-admins resolve via server config)
* - `arrContentId`: episodeId (Sonarr) or movieId (Radarr)
* - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr)
*
* **Error Responses:**
* - 403: Non-admin user attempts access
* - 403: User lacks permissions (admin or qualifying conditions required)
* - 400: Missing required fields or invalid arrType
* - 502: Failed to communicate with *arr instance
*
* **x-integration-notes:** This endpoint is used from the dashboard UI when an admin
* clicks "Blocklist + Re-search" on a failed download. The arr instance credentials
* are passed from the download object (which includes them for admin users).
* **x-integration-notes:** This endpoint is used from the dashboard UI when a qualified user or admin
* clicks "Blocklist + Re-search" on a stalled or failed download.
* security:
* - CookieAuth: []
* - CsrfToken: []
@@ -603,13 +629,13 @@ router.get('/stream', requireAuth, async (req, res) => {
* example:
* error: "Missing required fields"
* '403':
* description: Admin access required
* description: Permission denied (admin or qualifying conditions required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Admin access required"
* error: "Permission denied: admin or qualifying conditions required"
* '502':
* description: Failed to communicate with *arr instance
* content:
@@ -650,13 +676,9 @@ router.get('/stream', requireAuth, async (req, res) => {
router.post('/blocklist-search', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
return res.status(400).json({ error: 'Missing required fields' });
}
@@ -664,7 +686,34 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
}
const headers = { 'X-Api-Key': arrInstanceKey };
// Look up the download to verify permission
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
if (!download) {
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
return res.status(403).json({ error: 'Download not found or permission denied' });
}
// Check if user can blocklist this download
if (!canBlocklist(download, user.isAdmin)) {
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
}
// Resolve API key: use provided key (admin) or look up from instance config (non-admin)
let apiKey = arrInstanceKey;
if (!apiKey) {
const instances = arrType === 'sonarr' ? getSonarrInstances() : getRadarrInstances();
const instance = instances.find(inst => inst.url === arrInstanceUrl);
if (!instance || !instance.apiKey) {
console.error('[Blocklist] Instance not found or missing API key:', { arrType, arrInstanceUrl });
return res.status(400).json({ error: 'Instance not found or missing API key' });
}
apiKey = instance.apiKey;
}
const headers = { 'X-Api-Key': apiKey };
// Step 1: Remove from queue with blocklist=true
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
+16 -138
View File
@@ -1,119 +1,14 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const axios = require('axios');
const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache');
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const sanitizeError = require('../utils/sanitizeError');
const TagMatcher = require('../services/TagMatcher');
const DownloadAssembler = require('../services/DownloadAssembler');
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
// from a shared location. For now they are inlined here to keep dashboard.js
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
// into server/utils/dashboardHelpers.js in a later refactor.
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 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 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;
}
async function getEmbyUsers() {
const cached = cache.get('emby:users');
if (cached) return cached;
try {
const embyUrl = process.env.EMBY_URL;
const embyKey = process.env.EMBY_API_KEY;
if (!embyUrl || !embyKey) return new Map();
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
const users = res.data || [];
const map = new Map();
for (const u of users) {
if (!u.Name) continue;
const lower = u.Name.toLowerCase();
map.set(lower, u.Name);
map.set(sanitizeTagLabel(lower), u.Name);
}
cache.set('emby:users', map, 60000);
return map;
} catch (err) {
console.error('[History] Failed to fetch Emby users:', err.message);
return new Map();
}
}
function buildTagBadges(allTags, embyUserMap) {
return allTags.map(label => {
const lower = label.toLowerCase();
const sanitized = sanitizeTagLabel(label);
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
return { label, matchedUser };
});
}
// Extract episode info from a Sonarr history record.
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 };
}
// Find all episodes associated with a download by matching all history records
// that share the same source title. Returns sorted, deduplicated array.
function gatherEpisodes(titleLower, records) {
const episodes = [];
const seen = new Set();
for (const r of records) {
const rTitle = (r.sourceTitle || r.title || '').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;
}
/**
* Deduplicate history items so that for each unique content item (episode or
@@ -184,24 +79,7 @@ function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
return result;
}
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 getOmbiDetailsLink(mediaObj, type, ombiBaseUrl) {
if (!ombiBaseUrl || !mediaObj) return null;
const tmdbId = mediaObj.tmdbId;
if (!tmdbId) return null;
if (type === 'series') return `${ombiBaseUrl}/details/tv/${tmdbId}`;
if (type === 'movie') return `${ombiBaseUrl}/details/movie/${tmdbId}`;
return null;
}
/**
* @openapi
@@ -358,7 +236,7 @@ router.get('/recent', requireAuth, async (req, res) => {
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
fetchSonarrHistory(since),
fetchRadarrHistory(since),
showAll ? getEmbyUsers() : Promise.resolve(new Map())
showAll ? TagMatcher.getEmbyUsers() : Promise.resolve(new Map())
]);
// Build tag maps from the cached poll data where available,
@@ -379,8 +257,8 @@ router.get('/recent', requireAuth, async (req, res) => {
const series = record.series;
if (!series) continue;
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
@@ -395,17 +273,17 @@ router.get('/recent', requireAuth, async (req, res) => {
outcome,
title: sourceTitle,
seriesName: series.title,
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
coverArt: getCoverArt(series),
episodes: DownloadAssembler.gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
coverArt: DownloadAssembler.getCoverArt(series),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getSonarrLink(series),
ombiLink: getOmbiDetailsLink(series, 'series', ombiBaseUrl),
arrLink: DownloadAssembler.getSonarrLink(series),
ombiLink: DownloadAssembler.getOmbiDetailsLink(series, 'series', ombiBaseUrl),
ombiTooltip: 'View in Ombi',
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.episodeId != null ? record.episodeId : null
};
@@ -432,8 +310,8 @@ router.get('/recent', requireAuth, async (req, res) => {
const movie = record.movie;
if (!movie) continue;
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
@@ -447,16 +325,16 @@ router.get('/recent', requireAuth, async (req, res) => {
outcome,
title: record.sourceTitle || record.title || movie.title,
movieName: movie.title,
coverArt: getCoverArt(movie),
coverArt: DownloadAssembler.getCoverArt(movie),
completedAt: record.date,
quality,
instanceName: record._instanceName || null,
arrLink: getRadarrLink(movie),
ombiLink: getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
arrLink: DownloadAssembler.getRadarrLink(movie),
ombiLink: DownloadAssembler.getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
ombiTooltip: 'View in Ombi',
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
_contentId: record.movieId != null ? record.movieId : null
};
+490
View File
@@ -0,0 +1,490 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
const { applyRequestFilters } = require('../utils/ombiFilters');
const router = express.Router();
/**
* @openapi
* /api/ombi/requests:
* get:
* tags: [Ombi]
* summary: Get Ombi requests
* description: |
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
* (filtered by Emby user mapping), while admins see all requests.
*
* Supports server-side filtering by media type, request status, title search,
* and sorting by requested date or title.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* parameters:
* - name: type
* in: query
* schema:
* type: array
* items:
* type: string
* enum: [movie, tv, all]
* default: [all]
* description: Filter by media type. Omit or use `all` for both.
* style: form
* explode: true
* - name: status
* in: query
* schema:
* type: array
* items:
* type: string
* enum: [pending, approved, available, denied]
* description: Filter by request status. Omit for all statuses.
* style: form
* explode: true
* - name: sort
* in: query
* schema:
* type: string
* enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc]
* default: requestedDate_desc
* description: Sort mode.
* - name: search
* in: query
* schema:
* type: string
* description: Case-insensitive substring match on title.
* - name: showAll
* in: query
* schema:
* type: string
* enum: ['true', 'false']
* description: Admin only. Show all users' requests.
* responses:
* '200':
* description: Ombi requests retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* type: string
* example: "username"
* isAdmin:
* type: boolean
* example: false
* showAll:
* type: boolean
* example: false
* requests:
* type: object
* properties:
* movie:
* type: array
* items:
* $ref: '#/components/schemas/OmbiRequest'
* tv:
* type: array
* items:
* $ref: '#/components/schemas/OmbiRequest'
* total:
* type: integer
* example: 5
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/requests', requireAuth, async (req, res) => {
try {
const user = req.user;
const isAdmin = user.isAdmin;
const username = user.name;
const showAll = isAdmin && req.query.showAll === 'true';
const arrRetrieverRegistry = require('../utils/arrRetrievers');
// initialize() is idempotent - cheap no-op if already initialized
await arrRetrieverRegistry.initialize();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
// Filter by user if not admin or if showAll is false
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
// Tag with mediaType and flatten for filtering/sorting
const allRequests = [
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
];
// Parse query params
let types = req.query.type;
let statuses = req.query.status;
const sort = req.query.sort || 'requestedDate_desc';
const search = req.query.search || '';
// Normalise to arrays
if (typeof types === 'string') types = [types];
if (typeof statuses === 'string') statuses = [statuses];
// Apply filters and sorting
const filtered = applyRequestFilters(allRequests, { types, statuses, sort, search });
// Split back into movie/tv
const movie = filtered.filter(r => r.mediaType === 'movie');
const tv = filtered.filter(r => r.mediaType === 'tv');
const total = filtered.length;
res.json({
user: username,
isAdmin,
showAll,
requests: { movie, tv },
total
});
} catch (error) {
logToFile(`[Ombi] Error fetching requests: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi requests' });
}
});
/**
* @openapi
* /api/ombi/webhook/enable:
* post:
* tags: [Ombi]
* summary: Enable Ombi webhook
* description: |
* Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
*
* **Authentication:** Requires cookie authentication.
* **CSRF:** Requires X-CSRF-Token header matching csrf_token cookie.
* security:
* - cookieAuth: []
* requestBody:
* required: false
* responses:
* '200':
* description: Webhook enabled successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* webhookUrl:
* type: string
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* example: "your-ombi-api-key"
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/enable', requireAuth, async (req, res) => {
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 ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Call Ombi API to register webhook
const axios = require('axios');
const response = await axios.post(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
enabled: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
},
{
headers: {
'ApiKey': ombiInst.apiKey,
'Content-Type': 'application/json'
}
}
);
logToFile(`[Ombi] Webhook enabled: ${webhookUrl}`);
res.json({
success: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
});
} catch (error) {
logToFile(`[Ombi] Error enabling webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to enable Ombi webhook' });
}
});
/**
* @openapi
* /api/ombi/webhook/status:
* get:
* tags: [Ombi]
* summary: Get Ombi webhook status
* description: |
* Returns the current Ombi webhook configuration status and metrics.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* responses:
* '200':
* description: Webhook status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* example: true
* webhookUrl:
* type: string
* nullable: true
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* nullable: true
* example: "your-ombi-api-key"
* triggers:
* type: object
* properties:
* requestAvailable:
* type: boolean
* example: true
* requestApproved:
* type: boolean
* example: true
* requestDeclined:
* type: boolean
* example: true
* requestPending:
* type: boolean
* example: true
* requestProcessing:
* type: boolean
* example: true
* stats:
* type: object
* properties:
* eventsReceived:
* type: integer
* example: 10
* pollsSkipped:
* type: integer
* example: 5
* lastWebhookTimestamp:
* type: integer
* example: 1716326400000
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/webhook/status', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
// Webhooks require SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET to be configured
if (!sofarrBaseUrl || !webhookSecret) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInst = ombiInstances[0];
// Call Ombi API to get webhook status
const axios = require('axios');
const response = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
);
const webhookConfig = response.data;
// Get webhook metrics from cache
const metrics = cache.getWebhookMetrics(ombiInst.url);
res.json({
enabled: webhookConfig.enabled || false,
webhookUrl: webhookConfig.webhookUrl || null,
applicationToken: webhookConfig.applicationToken || null,
// Note: Ombi may support per-trigger toggles, but we currently treat
// them as all-on or all-off based on webhookConfig.enabled
triggers: {
requestAvailable: webhookConfig.enabled || false,
requestApproved: webhookConfig.enabled || false,
requestDeclined: webhookConfig.enabled || false,
requestPending: webhookConfig.enabled || false,
requestProcessing: webhookConfig.enabled || false
},
stats: metrics ? {
eventsReceived: metrics.eventsReceived || 0,
pollsSkipped: metrics.pollsSkipped || 0,
lastWebhookTimestamp: metrics.lastWebhookTimestamp || null
} : null
});
} catch (error) {
logToFile(`[Ombi] Error fetching webhook status: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi webhook status' });
}
});
/**
* @openapi
* /api/ombi/webhook/test:
* post:
* tags: [Ombi]
* summary: Test Ombi webhook
* description: |
* Sends a test webhook event to the Sofarr Ombi webhook endpoint.
*
* **Authentication:** Requires cookie authentication and CSRF token.
* security:
* - cookieAuth: []
* - CsrfToken: []
* requestBody:
* required: false
* responses:
* '200':
* description: Test webhook sent successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/test', requireAuth, async (req, res) => {
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 ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Simulate a test webhook event
const axios = require('axios');
await axios.post(webhookUrl, {
notificationType: 'RequestAvailable',
requestId: 0,
requestedUser: 'test',
title: 'Test Request',
type: 'Movie',
requestStatus: 'Pending'
}, {
headers: {
'X-Sofarr-Webhook-Secret': webhookSecret,
'Content-Type': 'application/json'
}
});
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
res.json({ success: true });
} catch (error) {
logToFile(`[Ombi] Error testing webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to test Ombi webhook' });
}
});
module.exports = router;
+54 -18
View File
@@ -43,9 +43,13 @@ router.use(requireAuth);
// Get queue
router.get('/queue', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/queue`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -79,9 +83,13 @@ router.get('/queue', async (req, res) => {
*/
// Get history
router.get('/history', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
const response = await axios.get(`${instance.url}/api/v3/history`, {
headers: { 'X-Api-Key': instance.apiKey },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
@@ -92,9 +100,13 @@ router.get('/history', async (req, res) => {
// Get movie details
router.get('/movies/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -104,9 +116,13 @@ router.get('/movies/:id', async (req, res) => {
// Get all movies with tags
router.get('/movies', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/movie`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -164,9 +180,13 @@ router.get('/notifications', async (req, res) => {
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
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 }
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -176,9 +196,13 @@ router.get('/notifications/:id', async (req, res) => {
// POST /api/radarr/notifications - create notification
router.post('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -188,9 +212,13 @@ router.post('/notifications', async (req, res) => {
// PUT /api/radarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
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 }
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -200,9 +228,13 @@ router.put('/notifications/:id', async (req, res) => {
// DELETE /api/radarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
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 }
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -233,9 +265,13 @@ router.post('/notifications/test', async (req, res) => {
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
+22 -4
View File
@@ -4,6 +4,16 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getSABnzbdInstances } = require('../utils/config');
// Helper to get first SABnzbd instance
function getFirstSABnzbdInstance() {
const instances = getSABnzbdInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
/**
* @openapi
@@ -32,11 +42,15 @@ router.use(requireAuth);
// GET /api/sabnzbd/queue
router.get('/queue', async (req, res) => {
const instance = getFirstSABnzbdInstance();
if (!instance) {
return res.status(503).json({ error: 'SABnzbd not configured' });
}
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${instance.url}/api`, {
params: {
mode: 'queue',
apikey: process.env.SABNZBD_API_KEY,
apikey: instance.apiKey,
output: 'json'
}
});
@@ -72,11 +86,15 @@ router.get('/queue', async (req, res) => {
*/
// GET /api/sabnzbd/history
router.get('/history', async (req, res) => {
const instance = getFirstSABnzbdInstance();
if (!instance) {
return res.status(503).json({ error: 'SABnzbd not configured' });
}
try {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
const response = await axios.get(`${instance.url}/api`, {
params: {
mode: 'history',
apikey: process.env.SABNZBD_API_KEY,
apikey: instance.apiKey,
output: 'json',
limit: req.query.limit || 50
}
+54 -18
View File
@@ -43,9 +43,13 @@ router.use(requireAuth);
// Get queue
router.get('/queue', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/queue`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -79,9 +83,13 @@ router.get('/queue', async (req, res) => {
*/
// Get history
router.get('/history', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
const response = await axios.get(`${instance.url}/api/v3/history`, {
headers: { 'X-Api-Key': instance.apiKey },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
@@ -92,9 +100,13 @@ router.get('/history', async (req, res) => {
// Get series details
router.get('/series/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -104,9 +116,13 @@ router.get('/series/:id', async (req, res) => {
// Get all series with tags
router.get('/series', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/series`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -164,9 +180,13 @@ router.get('/notifications', async (req, res) => {
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
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 }
const response = await axios.get(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -176,9 +196,13 @@ router.get('/notifications/:id', async (req, res) => {
// POST /api/sonarr/notifications - create notification
router.post('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.post(`${instance.url}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -188,9 +212,13 @@ router.post('/notifications', async (req, res) => {
// PUT /api/sonarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
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 }
const response = await axios.put(`${instance.url}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -200,9 +228,13 @@ router.put('/notifications/:id', async (req, res) => {
// DELETE /api/sonarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
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 }
const response = await axios.delete(`${instance.url}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
@@ -233,9 +265,13 @@ router.post('/notifications/test', async (req, res) => {
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
+257 -7
View File
@@ -2,13 +2,71 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const { logToFile } = require('../utils/logger');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
const { extractRequestedUser } = require('../utils/ombiHelpers');
const requireAuth = require('../middleware/requireAuth');
const router = express.Router();
/**
* @openapi
* /api/webhook/config:
* get:
* tags: [Webhook]
* summary: Get webhook configuration status
* description: |
* Returns whether the required webhook configuration (SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
* is properly configured. Used by the webhooks panel to determine if webhooks can be enabled.
*
* **Authentication:** Requires valid `emby_user` cookie.
* security:
* - CookieAuth: []
* responses:
* '200':
* description: Webhook configuration status
* content:
* application/json:
* schema:
* type: object
* properties:
* valid:
* type: boolean
* description: true if both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured
* example: true
* missing:
* type: array
* items:
* type: string
* description: List of missing configuration items
* example: []
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/config', requireAuth, (req, res) => {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
const missing = [];
if (!sofarrBaseUrl) {
missing.push('SOFARR_BASE_URL');
}
if (!webhookSecret) {
missing.push('SOFARR_WEBHOOK_SECRET');
}
res.json({
valid: missing.length === 0,
missing
});
});
// 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.
@@ -27,7 +85,9 @@ const VALID_EVENT_TYPES = new Set([
'DownloadFolderImported', 'ImportFailed',
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
// Ombi notification types
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
@@ -73,6 +133,15 @@ const HISTORY_EVENTS = new Set([
'EpisodeFileRenamedBySeries'
]);
// Ombi event types — all Ombi events refresh the requests cache
const OMBI_EVENTS = new Set([
'RequestAvailable',
'RequestApproved',
'RequestDeclined',
'RequestPending',
'RequestProcessing'
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* @param {Object} req - Express request object
@@ -107,19 +176,20 @@ function validateWebhookSecret(req) {
*
* 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
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
* @param {string} eventType - the eventType from the webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
const affectsQueue = QUEUE_EVENTS.has(eventType);
const affectsHistory = HISTORY_EVENTS.has(eventType);
const affectsOmbi = OMBI_EVENTS.has(eventType);
if (!affectsQueue && !affectsHistory) {
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
if (!affectsQueue && !affectsHistory && !affectsOmbi) {
logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`);
return;
}
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`);
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`);
// Ensure retrievers are initialized (idempotent)
await arrRetrieverRegistry.initialize();
@@ -184,6 +254,14 @@ async function processWebhookEvent(serviceType, eventType) {
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
}
} else if (serviceType === 'ombi') {
const ombiInstances = getOmbiInstances();
if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
}
}
// Broadcast to all SSE subscribers using the same mechanism poller.js uses.
@@ -512,4 +590,176 @@ router.post('/radarr', webhookLimiter, (req, res) => {
}
});
/**
* @openapi
* /api/webhook/ombi:
* post:
* tags: [Webhook]
* summary: Ombi webhook receiver
* description: |
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
*
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Ombi, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Payload validation (must be JSON object with notificationType, requestId)
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
* - Replay protection: rejects duplicate events within 5-minute window
*
* **Event Classification:**
* - OMBI_EVENTS (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing):
* Refreshes `poll:ombi-requests` cache
*
* **Processing Flow:**
* 1. Validate secret 401 if invalid
* 2. Validate payload 400 if invalid
* 3. Check replay cache 200 with duplicate=true if replay
* 4. Update webhook metrics (enables smart polling skip)
* 5. Return 200 immediately (don't wait for background processing)
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
*
* **x-integration-notes:** Configure Ombi webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Application Token: OMBI_API_KEY
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* notificationType:
* type: string
* example: "RequestAvailable"
* requestId:
* type: integer
* example: 123
* requestedUser:
* type: string
* example: "username"
* title:
* type: string
* example: "Movie Title"
* type:
* type: string
* example: "Movie"
* requestStatus:
* type: string
* example: "Available"
* example:
* notificationType: "RequestAvailable"
* requestId: 123
* requestedUser: "username"
* title: "Movie Title"
* type: "Movie"
* requestStatus: "Available"
* responses:
* '200':
* description: Event received and accepted
* content:
* application/json:
* schema:
* type: object
* properties:
* received:
* type: boolean
* example: true
* duplicate:
* type: boolean
* description: True if this event was already processed (replay protection)
* example: false
* examples:
* newEvent:
* received: true
* duplicate: false
* duplicateEvent:
* received: true
* duplicate: true
* '401':
* description: Invalid or missing webhook secret
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Unauthorized"
* '400':
* description: Invalid payload or unknown event type
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* examples:
* invalidPayload:
* error: "Payload must be a JSON object"
* unknownEventType:
* error: "Unknown notificationType: InvalidEvent"
* x-code-samples:
* - lang: curl
* label: cURL (from Ombi)
* source: |
* curl -X POST http://sofarr:3001/api/webhook/ombi \
* -H "Content-Type: application/json" \
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
* -d '{"notificationType":"RequestAvailable","requestId":123,"requestedUser":"username","title":"Movie Title","type":"Movie","requestStatus":"Available"}'
*/
router.post('/ombi', webhookLimiter, (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Ombi uses notificationType instead of eventType
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
const eventType = notificationType || req.body.eventType;
// Extract username from requestedUser (handles both object and string formats)
const username = extractRequestedUser(req.body);
if (!eventType || !OMBI_EVENTS.has(eventType)) {
logToFile(`[Webhook] Ombi payload rejected: invalid or missing notificationType`);
return res.status(400).json({ error: 'Invalid or missing notificationType' });
}
// Use applicationUrl as instance identifier for replay protection
const instanceName = applicationUrl || 'ombi';
// Use requestId + eventType + current time as replay key
const eventDate = req.body.requestedDate || new Date().toISOString();
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Ombi event received - Type: ${eventType}, RequestId: ${requestId}, User: ${username}`);
logToFile(`[Webhook] Ombi payload: ${JSON.stringify(req.body)}`);
// Update webhook metrics for polling optimization
const ombiInstances = getOmbiInstances();
const inst = ombiInstances[0]; // Use first Ombi instance
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Ombi instance: ${inst.name} (${inst.url})`);
}
// Background cache refresh + SSE broadcast (fire-and-forget)
processWebhookEvent('ombi', eventType).catch(err => {
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
});
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Ombi error: ${error.message}`);
res.status(200).json({ received: true });
}
});
module.exports = router;
+24 -20
View File
@@ -204,16 +204,17 @@ async function matchSabSlots(slots, context) {
};
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
// Expose ARR IDs to non-admins for blocklist functionality
dlObj.arrQueueId = sonarrMatch.id;
dlObj.arrType = 'sonarr';
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
dlObj.arrContentId = sonarrMatch.episodeId || null;
dlObj.arrContentType = 'episode';
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.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);
addOmbiMatching(dlObj, series, context);
@@ -257,16 +258,17 @@ async function matchSabSlots(slots, context) {
};
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
// Expose ARR IDs to non-admins for blocklist functionality
dlObj.arrQueueId = radarrMatch.id;
dlObj.arrType = 'radarr';
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
dlObj.arrContentId = radarrMatch.movieId || null;
dlObj.arrContentType = 'movie';
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.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);
addOmbiMatching(dlObj, movie, context);
@@ -444,16 +446,17 @@ async function matchTorrents(torrents, context) {
});
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) download.importIssues = issues;
// Expose ARR IDs to non-admins for blocklist functionality
download.arrQueueId = sonarrMatch.id;
download.arrType = 'sonarr';
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
download.arrContentId = sonarrMatch.episodeId || null;
download.arrContentType = 'episode';
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = DownloadAssembler.getSonarrLink(series);
download.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);
addOmbiMatching(download, series, context);
@@ -489,16 +492,17 @@ async function matchTorrents(torrents, context) {
});
const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) download.importIssues = issues;
// Expose ARR IDs to non-admins for blocklist functionality
download.arrQueueId = radarrMatch.id;
download.arrType = 'radarr';
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
download.arrContentId = radarrMatch.movieId || null;
download.arrContentType = 'movie';
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.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);
addOmbiMatching(download, movie, context);
+3 -21
View File
@@ -7,6 +7,8 @@ const {
getOmbiInstances
} = require('./config');
const TagMatcher = require('../services/TagMatcher');
// Import retriever classes
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
@@ -377,27 +379,7 @@ const arrRetrieverRegistry = {
}
};
/**
* 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.
@@ -463,7 +445,7 @@ function matchDownload(download, arrItem, username, tagMap) {
const arrTags = getLabels(arrItem);
const allTags = [...dlTags, ...arrTags];
return allTags.some(tag => tagMatchesUser(tag, username));
return allTags.some(tag => TagMatcher.tagMatchesUser(tag, username.toLowerCase()));
}
// Attach matching helper functions to the registry object
+30 -6
View File
@@ -149,18 +149,42 @@ class DownloadClientRegistry {
const clients = this.getAllClients();
const result = {};
// Group by client type
if (clients.length === 0) {
return result;
}
// Reset fallback flags for qBittorrent clients
for (const client of clients) {
if (client.resetFallbackFlag) {
client.resetFallbackFlag();
}
}
// Fetch downloads from all clients in parallel
const results = await Promise.allSettled(
clients.map(async (client) => {
const downloads = await client.getActiveDownloads();
return {
type: client.getClientType(),
downloads
};
})
);
// Group by client type
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
const type = client.getClientType();
if (!result[type]) {
result[type] = [];
}
try {
const downloads = await client.getActiveDownloads();
result[type].push(...downloads);
} catch (error) {
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${error.message}`);
const res = results[i];
if (res.status === 'fulfilled' && res.value) {
result[type].push(...res.value.downloads);
} else {
const errorMsg = res.status === 'rejected' ? res.reason?.message : 'Unknown error';
logToFile(`[DownloadClientRegistry] Error fetching from ${client.name}: ${errorMsg}`);
}
}
+1 -10
View File
@@ -1,16 +1,7 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const fs = require('fs');
const path = require('path');
// Use DATA_DIR so the non-root container user (UID 1000) can write logs.
// Falls back to ../../data/server.log (same directory index.js uses).
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const logFile = fs.createWriteStream(path.join(DATA_DIR, 'server.log'), { flags: 'a' });
function logToFile(message) {
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
console.log(message);
}
module.exports = {
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Pure filter / sort / search utilities for Ombi requests.
* Must stay in sync with client/src/utils/ombiFilters.js
*/
/**
* Derive a single status string from an Ombi request object.
* Priority: available > denied > approved > pending > unknown
*
* @param {Object} request
* @returns {string} 'available' | 'denied' | 'approved' | 'pending' | 'unknown'
*/
function getRequestStatus(request) {
if (!request) return 'unknown';
if (request.available) return 'available';
if (request.denied) return 'denied';
if (request.approved) return 'approved';
if (request.requested) return 'pending';
return 'unknown';
}
/**
* Filter requests by media type.
*
* @param {Array} requests
* @param {string[]} types - e.g. ['movie', 'tv'] or ['all']
* @returns {Array}
*/
function filterByType(requests, types) {
if (!types || types.length === 0) return requests;
const normalized = types.map(t => t.toLowerCase());
if (normalized.includes('all')) return requests;
return requests.filter(r => normalized.includes(r.mediaType));
}
/**
* Filter requests by status.
*
* @param {Array} requests
* @param {string[]} statuses - e.g. ['pending', 'approved', 'available', 'denied']
* @returns {Array}
*/
function filterByStatus(requests, statuses) {
if (!statuses || statuses.length === 0) return requests;
const normalized = statuses.map(s => s.toLowerCase());
return requests.filter(r => normalized.includes(getRequestStatus(r)));
}
/**
* Filter requests by case-insensitive title substring.
*
* @param {Array} requests
* @param {string} query
* @returns {Array}
*/
function filterBySearch(requests, query) {
if (!query || query.trim() === '') return requests;
const q = query.trim().toLowerCase();
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
}
/**
* Sort requests by the given sort mode.
*
* @param {Array} requests
* @param {string} sortMode - requestedDate_desc | requestedDate_asc | title_asc | title_desc
* @returns {Array} new sorted array
*/
function sortRequests(requests, sortMode) {
const sorted = [...requests];
switch (sortMode) {
case 'requestedDate_asc':
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return da - db;
});
case 'title_asc':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'title_desc':
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
case 'requestedDate_desc':
default:
return sorted.sort((a, b) => {
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
return db - da;
});
}
}
/**
* Apply all filters and sorting in one call.
*
* @param {Array} requests
* @param {Object} options
* @param {string[]} options.types
* @param {string[]} options.statuses
* @param {string} options.sort
* @param {string} options.search
* @returns {Array}
*/
function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
let result = [...requests];
result = filterByType(result, types);
result = filterByStatus(result, statuses);
result = filterBySearch(result, search);
result = sortRequests(result, sort);
return result;
}
module.exports = {
getRequestStatus,
filterByType,
filterByStatus,
filterBySearch,
sortRequests,
applyRequestFilters
};
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Helper functions for extracting user information from Ombi API responses.
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
* not a string, so we need to extract the username from the object.
*/
/**
* Extracts the username from an Ombi request object.
* Handles both the OmbiUser object format and legacy string format.
*
* @param {Object} request - The Ombi request object
* @returns {string} The extracted username, or empty string if not found
*/
function extractRequestedUser(request) {
if (!request) return '';
// Handle object format: OmbiStore.Entities.OmbiUser
if (request.requestedUser && typeof request.requestedUser === 'object') {
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
return request.requestedUser.alias ||
request.requestedUser.userAlias ||
request.requestedUser.userName ||
request.requestedUser.normalizedUserName ||
request.requestedByAlias || '';
}
// Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || '';
}
function filterRequestsByUser(requests, username, showAll) {
if (!Array.isArray(requests)) return [];
if (showAll || !username) return requests;
const usernameLower = username.toLowerCase();
return requests.filter(req => {
const requestedUser = extractRequestedUser(req);
return requestedUser.toLowerCase() === usernameLower;
});
}
module.exports = {
extractRequestedUser,
filterRequestsByUser
};
+21 -3
View File
@@ -5,7 +5,8 @@ const { initializeClients, getAllDownloads, getDownloadsByClientType } = require
const arrRetrieverRegistry = require('./arrRetrievers');
const {
getSonarrInstances,
getRadarrInstances
getRadarrInstances,
getOmbiInstances
} = require('./config');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
@@ -88,13 +89,14 @@ async function pollAllServices() {
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const ombiInstances = getOmbiInstances();
// 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`);
}
@@ -102,6 +104,7 @@ async function pollAllServices() {
// Determine which instances should be polled based on webhook activity
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
const shouldPollOmbi = fallbackTriggered || !shouldSkipInstancePolling(ombiInstances, 'ombi');
// All fetches in parallel, each individually timed
const results = await Promise.all([
@@ -133,6 +136,10 @@ async function pollAllServices() {
const tagsByType = await arrRetrieverRegistry.getTagsByType();
return tagsByType.radarr || [];
}) : timed('Radarr Tags', async () => []),
shouldPollOmbi ? timed('Ombi Requests', async () => {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
return ombiRequests;
}) : timed('Ombi Requests', async () => ({ movie: [], tv: [] })),
]);
const [
@@ -140,7 +147,8 @@ async function pollAllServices() {
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults }
{ result: radarrTagsResults },
{ result: ombiRequests }
] = results;
// Store per-task timings
@@ -282,6 +290,16 @@ async function pollAllServices() {
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
}
// Ombi
if (shouldPollOmbi) {
cache.set('poll:ombi-requests', ombiRequests, cacheTTL);
logToFile(`[Poller] Ombi requests cached: ${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows`);
} else {
// Extend TTL of existing cached data when polling is skipped
const existingOmbiRequests = cache.get('poll:ombi-requests');
if (existingOmbiRequests) cache.set('poll:ombi-requests', existingOmbiRequests, cacheTTL);
}
// qBittorrent (already set above in download clients section)
const elapsed = Date.now() - start;
+320
View File
@@ -0,0 +1,320 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/state.js
*
* Verifies the structure and initial values of the state object.
* This ensures the Ombi-related state fields are properly defined.
*/
import { state } from '../../client/src/state.js';
describe('state object', () => {
it('has ombiBaseUrl field initialized to null', () => {
expect(state).toHaveProperty('ombiBaseUrl');
expect(state.ombiBaseUrl).toBeNull();
});
it('has ombiRequests field initialized to null', () => {
expect(state).toHaveProperty('ombiRequests');
expect(state.ombiRequests).toBeNull();
});
it('has ombiWebhook field with correct structure', () => {
expect(state).toHaveProperty('ombiWebhook');
expect(state.ombiWebhook).toEqual({
enabled: false,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
});
it('has ombiWebhook triggers with all required fields', () => {
const { triggers } = state.ombiWebhook;
expect(triggers).toHaveProperty('requestAvailable');
expect(triggers).toHaveProperty('requestApproved');
expect(triggers).toHaveProperty('requestDeclined');
expect(triggers).toHaveProperty('requestPending');
expect(triggers).toHaveProperty('requestProcessing');
});
it('has all Ombi trigger fields initialized to false', () => {
const { triggers } = state.ombiWebhook;
expect(triggers.requestAvailable).toBe(false);
expect(triggers.requestApproved).toBe(false);
expect(triggers.requestDeclined).toBe(false);
expect(triggers.requestPending).toBe(false);
expect(triggers.requestProcessing).toBe(false);
});
it('has ombiWebhook stats initialized to null', () => {
expect(state.ombiWebhook.stats).toBeNull();
});
it('has ombiWebhook enabled initialized to false', () => {
expect(state.ombiWebhook.enabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Enabled tests with robust mocking
// ---------------------------------------------------------------------------
import { enableOmbiWebhook as apiEnableOmbiWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../../client/src/api.js';
import { renderWebhookStatus, enableOmbiWebhook as uiEnableOmbiWebhook, testOmbiWebhook as uiTestOmbiWebhook } from '../../client/src/ui/webhooks.js';
const mockFetch = vi.fn().mockImplementation((url, init) => {
if (url === '/api/ombi/webhook/enable') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true })
});
}
if (url === '/api/ombi/webhook/test') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true })
});
}
if (url === '/api/webhook/config') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: true })
});
}
if (url === '/api/sonarr/notifications') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([])
});
}
if (url === '/api/radarr/notifications') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve([])
});
}
if (url === '/api/ombi/webhook/status') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
enabled: true,
triggers: {
requestAvailable: true,
requestApproved: true,
requestDeclined: true,
requestPending: true,
requestProcessing: true
},
stats: {
eventsReceived: 10,
pollsSkipped: 5,
lastWebhookTimestamp: Date.now() - 60000
}
})
});
}
if (url === '/api/webhook/metrics') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({})
});
}
return Promise.resolve({
ok: false,
status: 404
});
});
function setupDomForOmbiWebhooks() {
document.body.innerHTML = `
<div id="webhooks-section"></div>
<div id="webhooks-content"></div>
<div id="webhooks-toggle"></div>
<div id="webhook-loading" class="hidden"></div>
<div id="sonarr-status"></div>
<button id="enable-sonarr-webhook"></button>
<button id="test-sonarr-webhook"></button>
<div id="sonarr-triggers"></div>
<div id="sonarr-stats"></div>
<div id="radarr-status"></div>
<button id="enable-radarr-webhook"></button>
<button id="test-radarr-webhook"></button>
<div id="radarr-triggers"></div>
<div id="radarr-stats"></div>
<div id="ombi-status"></div>
<button id="enable-ombi-webhook"></button>
<button id="test-ombi-webhook"></button>
<div id="ombi-triggers" class="hidden">
<div id="ombi-requestAvailable"></div>
<div id="ombi-requestApproved"></div>
<div id="ombi-requestDeclined"></div>
<div id="ombi-requestPending"></div>
<div id="ombi-requestProcessing"></div>
</div>
<div id="ombi-stats" class="hidden">
<div id="ombi-events"></div>
<div id="ombi-polls"></div>
<div id="ombi-last"></div>
</div>
`;
}
describe('frontend API functions (enableOmbiWebhook, testOmbiWebhook)', () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
state.csrfToken = 'test-csrf-token';
});
afterEach(() => {
delete global.fetch;
});
it('enableOmbiWebhook makes POST request to /api/ombi/webhook/enable', async () => {
const result = await apiEnableOmbiWebhook();
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/enable', {
method: 'POST',
headers: { 'X-CSRF-Token': 'test-csrf-token' }
});
expect(result).toEqual({ success: true });
});
it('testOmbiWebhook makes POST request to /api/ombi/webhook/test', async () => {
const result = await apiTestOmbiWebhook();
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/test', {
method: 'POST',
headers: { 'X-CSRF-Token': 'test-csrf-token' }
});
expect(result).toEqual({ success: true });
});
});
describe('frontend UI functions (webhooks.js Ombi functions)', () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
global.alert = vi.fn();
setupDomForOmbiWebhooks();
state.csrfToken = 'test-csrf-token';
// Set up default state for Ombi webhook
state.ombiWebhook = {
enabled: false,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
};
});
afterEach(() => {
delete global.fetch;
delete global.alert;
document.body.innerHTML = '';
});
it('renderWebhookStatus renders Ombi webhook status correctly', () => {
// 1. Test disabled state
state.ombiWebhook.enabled = false;
renderWebhookStatus();
expect(document.getElementById('ombi-status').textContent).toBe('○ Disabled');
expect(document.getElementById('enable-ombi-webhook').classList.contains('hidden')).toBe(false);
expect(document.getElementById('test-ombi-webhook').classList.contains('hidden')).toBe(true);
expect(document.getElementById('ombi-triggers').classList.contains('hidden')).toBe(true);
// 2. Test enabled state with triggers and stats
state.ombiWebhook.enabled = true;
state.ombiWebhook.triggers.requestAvailable = true;
state.ombiWebhook.triggers.requestApproved = true;
state.ombiWebhook.stats = {
eventsReceived: 42,
pollsSkipped: 17,
lastWebhookTimestamp: Date.now() - 3600000 // 1 hour ago
};
renderWebhookStatus();
expect(document.getElementById('ombi-status').textContent).toBe('● Enabled');
expect(document.getElementById('enable-ombi-webhook').classList.contains('hidden')).toBe(true);
expect(document.getElementById('test-ombi-webhook').classList.contains('hidden')).toBe(false);
expect(document.getElementById('ombi-triggers').classList.contains('hidden')).toBe(false);
// Check triggers rendering
expect(document.getElementById('ombi-requestAvailable').textContent).toBe('✓');
expect(document.getElementById('ombi-requestApproved').textContent).toBe('✓');
expect(document.getElementById('ombi-requestDeclined').textContent).toBe('✗');
// Check stats rendering
expect(document.getElementById('ombi-stats').classList.contains('hidden')).toBe(false);
expect(document.getElementById('ombi-events').textContent).toBe('42');
expect(document.getElementById('ombi-polls').textContent).toBe('17');
expect(document.getElementById('ombi-last').textContent).toBe('1h ago');
});
it('enableOmbiWebhook UI handler calls API and updates state', async () => {
// Mock the state returned by fetchWebhookStatus to enable it
mockFetch.mockImplementation((url) => {
if (url === '/api/ombi/webhook/enable') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
}
if (url === '/api/ombi/webhook/status') {
// Return updated state where it is enabled
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
enabled: true,
triggers: {
requestAvailable: true,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
})
});
}
// For all other config fetches, return basic values
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
});
await uiEnableOmbiWebhook();
// Should make POST call to enable
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/enable', {
method: 'POST',
headers: { 'X-CSRF-Token': 'test-csrf-token' }
});
// State should be updated
expect(state.ombiWebhook.enabled).toBe(true);
// Render the webhook status to update the DOM
renderWebhookStatus();
// UI should show enabled status
expect(document.getElementById('ombi-status').textContent).toBe('● Enabled');
});
it('testOmbiWebhook UI handler calls API and updates state', async () => {
await uiTestOmbiWebhook();
// Should make POST call to test
expect(mockFetch).toHaveBeenCalledWith('/api/ombi/webhook/test', {
method: 'POST',
headers: { 'X-CSRF-Token': 'test-csrf-token' }
});
// Should alert success
expect(global.alert).toHaveBeenCalledWith('Ombi webhook test sent successfully!');
});
});
+171
View File
@@ -0,0 +1,171 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/filters.js
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { state } from '../../../client/src/state.js';
import { initDownloadClientFilter, updateDownloadClientFilter, toggleClientSelection, toggleAllClients, updateSelectedCountDisplay } from '../../../client/src/ui/filters.js';
import { renderDownloads } from '../../../client/src/ui/downloads.js';
// Mock renderDownloads to verify re-render triggers
vi.mock('../../../client/src/ui/downloads.js', () => ({
renderDownloads: vi.fn()
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: (key) => store[key] || null,
setItem: (key, value) => { store[key] = value; },
removeItem: (key) => { delete store[key]; },
clear: () => { store = {}; }
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
function setupDOM() {
document.body.innerHTML = `
<div class="downloads-controls">
<div class="download-client-filter" id="download-client-filter">
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button">
<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 id="download-client-select-all" type="button">Select All</button>
<button 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>
`;
}
describe('initDownloadClientFilter', () => {
beforeEach(() => {
localStorageMock.clear();
state.downloadClients = [
{ id: 1, type: 'sabnzbd', name: 'SABnzbd' },
{ id: 2, type: 'qbittorrent', name: 'qBittorrent' }
];
state.selectedDownloadClients = [];
vi.clearAllMocks();
setupDOM();
initDownloadClientFilter();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('populates options list with checkboxes matching download clients', () => {
const optionsList = document.getElementById('download-client-options');
expect(optionsList.children.length).toBe(2);
const firstItem = optionsList.children[0];
const checkbox = firstItem.querySelector('input');
const label = firstItem.querySelector('label');
expect(checkbox.type).toBe('checkbox');
expect(checkbox.checked).toBe(false);
expect(label.textContent).toBe('SABnzbd');
});
it('restores checked state based on state.selectedDownloadClients', () => {
state.selectedDownloadClients = [0];
updateDownloadClientFilter();
const optionsList = document.getElementById('download-client-options');
const firstCheckbox = optionsList.children[0].querySelector('input');
const secondCheckbox = optionsList.children[1].querySelector('input');
expect(firstCheckbox.checked).toBe(true);
expect(secondCheckbox.checked).toBe(false);
});
it('clicking a checkbox updates selected state and triggers re-render', () => {
const optionsList = document.getElementById('download-client-options');
const firstCheckbox = optionsList.children[0].querySelector('input');
firstCheckbox.click();
expect(state.selectedDownloadClients).toEqual([0]);
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0]));
expect(renderDownloads).toHaveBeenCalled();
});
it('select all selects all clients and saves to storage', () => {
document.getElementById('download-client-select-all').click();
expect(state.selectedDownloadClients).toEqual([0, 1]);
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0, 1]));
expect(renderDownloads).toHaveBeenCalled();
const optionsList = document.getElementById('download-client-options');
expect(optionsList.children[0].querySelector('input').checked).toBe(true);
expect(optionsList.children[1].querySelector('input').checked).toBe(true);
});
it('deselect all clears all clients and saves empty list to storage', () => {
state.selectedDownloadClients = [0, 1];
updateDownloadClientFilter();
document.getElementById('download-client-deselect-all').click();
expect(state.selectedDownloadClients).toEqual([]);
expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([]));
expect(renderDownloads).toHaveBeenCalled();
const optionsList = document.getElementById('download-client-options');
expect(optionsList.children[0].querySelector('input').checked).toBe(false);
expect(optionsList.children[1].querySelector('input').checked).toBe(false);
});
it('toggles dropdown when dropdown button is clicked', () => {
const dropdown = document.getElementById('download-client-dropdown');
const btn = document.getElementById('download-client-dropdown-btn');
btn.click();
expect(dropdown.classList.contains('open')).toBe(true);
btn.click();
expect(dropdown.classList.contains('open')).toBe(false);
});
it('closes dropdown when clicking outside', () => {
const dropdown = document.getElementById('download-client-dropdown');
const btn = document.getElementById('download-client-dropdown-btn');
btn.click();
expect(dropdown.classList.contains('open')).toBe(true);
document.body.click();
expect(dropdown.classList.contains('open')).toBe(false);
});
it('updates selected text display correctly based on count', () => {
const selectedText = document.getElementById('download-client-selected-text');
state.selectedDownloadClients = [];
updateSelectedCountDisplay();
expect(selectedText.textContent).toBe('All clients');
state.selectedDownloadClients = [0];
updateSelectedCountDisplay();
expect(selectedText.textContent).toBe('SABnzbd');
state.selectedDownloadClients = [0, 1];
updateSelectedCountDisplay();
expect(selectedText.textContent).toBe('All clients'); // Since it's all of them
});
});
+253
View File
@@ -0,0 +1,253 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/requestFilters.js
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { state } from '../../../client/src/state.js';
import { initRequestFilters } from '../../../client/src/ui/requestFilters.js';
import { renderRequests } from '../../../client/src/ui/requests.js';
// Mock renderRequests to verify re-render triggers
vi.mock('../../../client/src/ui/requests.js', () => ({
renderRequests: vi.fn()
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: (key) => store[key] || null,
setItem: (key, value) => { store[key] = value; },
removeItem: (key) => { delete store[key]; },
clear: () => { store = {}; }
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
function setupDOM() {
document.body.innerHTML = `
<div class="requests-controls">
<div class="request-filter" id="request-type-filter">
<button class="request-filter-btn" id="request-type-filter-btn" type="button">
<span id="request-type-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
<div class="request-filter-dropdown-header">
<button id="request-type-select-all" type="button">Select All</button>
<button id="request-type-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-type-options">
<div class="request-filter-option" data-value="movie">
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
<label for="request-type-movie">Movies</label>
</div>
<div class="request-filter-option" data-value="tv">
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
<label for="request-type-tv">TV Shows</label>
</div>
</div>
</div>
</div>
<div class="request-filter" id="request-status-filter">
<button class="request-filter-btn" id="request-status-filter-btn" type="button">
<span id="request-status-selected-text">All</span>
<span class="dropdown-arrow"></span>
</button>
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
<div class="request-filter-dropdown-header">
<button id="request-status-select-all" type="button">Select All</button>
<button id="request-status-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-status-options">
<div class="request-filter-option" data-value="pending">
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
<label for="request-status-pending">Pending</label>
</div>
<div class="request-filter-option" data-value="approved">
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
<label for="request-status-approved">Approved</label>
</div>
<div class="request-filter-option" data-value="available">
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
<label for="request-status-available">Available</label>
</div>
<div class="request-filter-option" data-value="denied">
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
<label for="request-status-denied">Denied</label>
</div>
</div>
</div>
</div>
<div class="request-sort">
<select id="request-sort-select" class="request-sort-select">
<option value="requestedDate_desc">Newest to oldest</option>
<option value="requestedDate_asc">Oldest to newest</option>
<option value="title_asc">AZ</option>
<option value="title_desc">ZA</option>
</select>
</div>
<div class="request-search">
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
</div>
</div>
`;
}
describe('initRequestFilters', () => {
beforeEach(() => {
localStorageMock.clear();
state.selectedRequestTypes = ['movie', 'tv'];
state.selectedRequestStatuses = [];
state.requestSortMode = 'requestedDate_desc';
state.requestSearchQuery = '';
vi.clearAllMocks();
setupDOM();
initRequestFilters();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('restores saved type selections from localStorage', () => {
localStorageMock.setItem('sofarr-request-types', JSON.stringify(['tv']));
state.selectedRequestTypes = ['tv'];
setupDOM();
initRequestFilters();
const movieCb = document.getElementById('request-type-movie');
const tvCb = document.getElementById('request-type-tv');
expect(movieCb.checked).toBe(false);
expect(tvCb.checked).toBe(true);
});
it('restores saved status selections from localStorage', () => {
localStorageMock.setItem('sofarr-request-statuses', JSON.stringify(['pending', 'approved']));
state.selectedRequestStatuses = ['pending', 'approved'];
setupDOM();
initRequestFilters();
expect(document.getElementById('request-status-pending').checked).toBe(true);
expect(document.getElementById('request-status-approved').checked).toBe(true);
expect(document.getElementById('request-status-available').checked).toBe(false);
});
it('restores saved sort mode', () => {
localStorageMock.setItem('sofarr-request-sort', 'title_asc');
state.requestSortMode = 'title_asc';
setupDOM();
initRequestFilters();
expect(document.getElementById('request-sort-select').value).toBe('title_asc');
});
it('restores saved search query', () => {
localStorageMock.setItem('sofarr-request-search', 'batman');
state.requestSearchQuery = 'batman';
setupDOM();
initRequestFilters();
expect(document.getElementById('request-search-input').value).toBe('batman');
});
it('toggles type checkbox and updates state', () => {
const movieCb = document.getElementById('request-type-movie');
movieCb.click();
expect(state.selectedRequestTypes).toEqual(['tv']);
expect(localStorageMock.getItem('sofarr-request-types')).toBe(JSON.stringify(['tv']));
expect(renderRequests).toHaveBeenCalled();
});
it('toggles status checkbox and updates state', () => {
const pendingCb = document.getElementById('request-status-pending');
pendingCb.click();
expect(state.selectedRequestStatuses).toEqual(['pending']);
expect(localStorageMock.getItem('sofarr-request-statuses')).toBe(JSON.stringify(['pending']));
expect(renderRequests).toHaveBeenCalled();
});
it('select all sets all types', () => {
state.selectedRequestTypes = [];
setupDOM();
initRequestFilters();
document.getElementById('request-type-select-all').click();
expect(state.selectedRequestTypes).toEqual(['movie', 'tv']);
expect(renderRequests).toHaveBeenCalled();
});
it('deselect all clears all types', () => {
document.getElementById('request-type-deselect-all').click();
expect(state.selectedRequestTypes).toEqual([]);
expect(renderRequests).toHaveBeenCalled();
});
it('select all sets all statuses', () => {
document.getElementById('request-status-select-all').click();
expect(state.selectedRequestStatuses).toEqual(['pending', 'approved', 'available', 'denied']);
expect(renderRequests).toHaveBeenCalled();
});
it('deselect all clears all statuses', () => {
state.selectedRequestStatuses = ['pending', 'approved'];
setupDOM();
initRequestFilters();
document.getElementById('request-status-deselect-all').click();
expect(state.selectedRequestStatuses).toEqual([]);
expect(renderRequests).toHaveBeenCalled();
});
it('changing sort select updates state', () => {
const select = document.getElementById('request-sort-select');
select.value = 'title_asc';
select.dispatchEvent(new Event('change'));
expect(state.requestSortMode).toBe('title_asc');
expect(localStorageMock.getItem('sofarr-request-sort')).toBe('title_asc');
expect(renderRequests).toHaveBeenCalled();
});
it('typing in search input updates state after debounce', async () => {
const input = document.getElementById('request-search-input');
input.value = 'bat';
input.dispatchEvent(new Event('input'));
// State shouldn't update immediately due to debounce
expect(state.requestSearchQuery).toBe('');
expect(renderRequests).not.toHaveBeenCalled();
// Wait for debounce
await new Promise(r => setTimeout(r, 250));
expect(state.requestSearchQuery).toBe('bat');
expect(localStorageMock.getItem('sofarr-request-search')).toBe('bat');
expect(renderRequests).toHaveBeenCalled();
});
it('clicking outside closes dropdowns', () => {
const typeDropdown = document.getElementById('request-type-filter-dropdown');
const typeBtn = document.getElementById('request-type-filter-btn');
typeBtn.click();
expect(typeDropdown.classList.contains('open')).toBe(true);
document.body.click();
expect(typeDropdown.classList.contains('open')).toBe(false);
});
});
+193 -5
View File
@@ -352,7 +352,7 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.downloadPath).toBeDefined();
});
it('does not include admin-only fields for non-admin user', async () => {
it('includes ARR ID fields for non-admin user (for blocklist functionality)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app);
seedSabSonarrCache();
@@ -362,8 +362,14 @@ describe('GET /api/dashboard/user-downloads', () => {
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();
// ARR IDs are now exposed to non-admins for blocklist functionality
expect(dl.arrQueueId).toBe(1001);
expect(dl.arrType).toBe('sonarr');
// But sensitive fields remain admin-only
expect(dl.arrInstanceKey).toBeUndefined();
expect(dl.arrLink).toBeUndefined();
expect(dl.downloadPath).toBeUndefined();
expect(dl.targetPath).toBeUndefined();
});
it('does not return downloads tagged for a different user', async () => {
@@ -739,17 +745,74 @@ describe('POST /api/dashboard/blocklist-search', () => {
expect(res.status).toBe(403);
});
it('returns 403 for non-admin user', async () => {
it('returns 403 for non-admin user without qualifying conditions', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
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);
expect(res.body.error).toMatch(/permission denied/i);
mockGetAllDownloads.mockRestore();
});
it('returns 403 for non-admin when download not found in active downloads', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return empty array (download not found)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
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, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/download not found/i);
mockGetAllDownloads.mockRestore();
});
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
]);
// Mock Sonarr DELETE and command endpoints
nock(SONARR_BASE)
.delete('/api/v3/queue/1')
.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: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 400 when required fields are missing', async () => {
@@ -780,6 +843,12 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
@@ -795,12 +864,19 @@ describe('POST /api/dashboard/blocklist-search', () => {
.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);
mockGetAllDownloads.mockRestore();
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
]);
nock(RADARR_BASE)
.delete('/api/v3/queue/2001')
.query({ removeFromClient: 'true', blocklist: 'true' })
@@ -816,12 +892,19 @@ describe('POST /api/dashboard/blocklist-search', () => {
.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);
mockGetAllDownloads.mockRestore();
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query(true)
@@ -833,5 +916,110 @@ describe('POST /api/dashboard/blocklist-search', () => {
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
mockGetAllDownloads.mockRestore();
});
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/stream (SSE)
// ---------------------------------------------------------------------------
const OMBI_STREAM_FIXTURE = {
movie: [
{ id: 1, title: 'Movie 1', requestedUser: { userName: 'alice' } },
{ id: 2, title: 'Movie 2', requestedUser: { userName: 'bob' } }
],
tv: [
{ id: 3, title: 'TV 1', requestedUser: { userName: 'alice' } },
{ id: 4, title: 'TV 2', requestedUser: { userName: 'bob' } }
]
};
describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () => {
let appInstance;
beforeEach(() => {
appInstance = createApp({ skipRateLimits: true });
// Seed basic cached values to prevent on-demand poll
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
});
it('filters Ombi requests by user when showAll is false', async () => {
const { cookies } = await loginAs(appInstance);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('alice');
expect(data.ombiRequests.movie).toHaveLength(1);
expect(data.ombiRequests.movie[0].title).toBe('Movie 1');
expect(data.ombiRequests.tv).toHaveLength(1);
expect(data.ombiRequests.tv[0].title).toBe('TV 1');
});
it('returns all Ombi requests when admin with showAll is true', async () => {
const { cookies } = await loginAs(appInstance, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
nock(EMBY_BASE)
.get('/Users')
.reply(200, [EMBY_USER, EMBY_ADMIN_USER]);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ showAll: 'true', testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('admin');
expect(data.ombiRequests.movie).toHaveLength(2);
expect(data.ombiRequests.tv).toHaveLength(2);
});
});
+997
View File
@@ -0,0 +1,997 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Integration tests for server/routes/ombi.js
*
* Strategy:
* - createApp({ skipRateLimits: true }) for a real Express instance
* - nock intercepts Emby auth so we can obtain a valid session cookie
* - Mock cache.getWebhookMetrics() for webhook status endpoint
* - nock intercepts Ombi API calls for webhook status/test endpoints
*
* Covers:
* GET /api/ombi/requests auth guard, showAll parameter, user filtering (skipped - requires complex arrRetrieverRegistry mocking)
* GET /api/ombi/webhook/status auth guard, extended response with triggers and stats
* POST /api/ombi/webhook/enable auth guard, Ombi configuration check
* POST /api/ombi/webhook/test auth guard, Ombi configuration check, test webhook
*/
import request from 'supertest';
import nock from 'nock';
import { beforeEach, afterEach, vi } 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 arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EMBY_BASE = 'https://emby.test';
const OMBI_BASE = 'https://ombi.test';
const SOFARR_BASE = 'https://sofarr.test';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EMBY_AUTH_BODY = {
AccessToken: 'test-emby-token-abc123',
User: { Id: 'user-id-001', Name: 'TestUser' }
};
const EMBY_USER_BODY = {
Id: 'user-id-001',
Name: 'TestUser',
Policy: { IsAdministrator: false }
};
const EMBY_ADMIN_BODY = {
Id: 'admin-id-001',
Name: 'AdminUser',
Policy: { IsAdministrator: true }
};
const OMBI_REQUESTS = {
movie: [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' },
{ id: 2, title: 'Admin Movie', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'movie' }
],
tv: [
{ id: 3, title: 'Test Show', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'tv' },
{ id: 4, title: 'Admin Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv' }
]
};
const OMBI_WEBHOOK_CONFIG = {
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
applicationToken: 'test-ombi-api-key'
};
const OMBI_WEBHOOK_METRICS = {
eventsReceived: 10,
pollsSkipped: 5,
lastWebhookTimestamp: 1716326400000
};
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, EMBY_AUTH_BODY);
nock(EMBY_BASE)
.get(/\/Users\//)
.reply(200, userBody);
}
function setupOmbiRequestMocks(movieRequests = OMBI_REQUESTS.movie, tvRequests = OMBI_REQUESTS.tv) {
nock(OMBI_BASE)
.get('/api/v1/Request/movie')
.reply(200, movieRequests);
nock(OMBI_BASE)
.get('/api/v1/Request/tv')
.reply(200, tvRequests);
}
function makeApp() {
process.env.EMBY_URL = EMBY_BASE;
process.env.OMBI_INSTANCES = JSON.stringify([
{ id: 'ombi-1', name: 'Test Ombi', url: OMBI_BASE, apiKey: 'test-ombi-key' }
]);
process.env.SOFARR_BASE_URL = SOFARR_BASE;
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret';
return createApp({ skipRateLimits: true });
}
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
const userBody = isAdmin ? EMBY_ADMIN_BODY : EMBY_USER_BODY;
interceptSuccessfulLogin(userBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username, password: 'password' });
const cookies = res.headers['set-cookie'];
const csrfToken = res.body.csrfToken;
return { cookies, csrfToken };
}
// ---------------------------------------------------------------------------
// Setup/Teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
delete process.env.EMBY_URL;
delete process.env.OMBI_INSTANCES;
delete process.env.SOFARR_BASE_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
});
// ---------------------------------------------------------------------------
// GET /api/ombi/requests
// ---------------------------------------------------------------------------
describe('GET /api/ombi/requests', () => {
let app;
beforeEach(() => {
app = makeApp();
setupOmbiRequestMocks();
// Reset the singleton registry so it re-initializes on each request
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
});
afterEach(() => {
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
});
it('returns 401 when not authenticated', async () => {
const res = await request(app)
.get('/api/ombi/requests')
.expect(401);
expect(res.body.error).toBe('Not authenticated');
});
it('returns user-filtered requests for non-admin users', async () => {
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.user).toBe('TestUser');
expect(res.body.isAdmin).toBe(false);
expect(res.body.showAll).toBe(false);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser');
expect(res.body.requests.tv).toHaveLength(1);
expect(res.body.requests.tv[0].requestedUser.userName).toBe('testuser');
expect(res.body.total).toBe(2);
});
it('returns all requests when admin with showAll=true', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.user).toBe('AdminUser');
expect(res.body.isAdmin).toBe(true);
expect(res.body.showAll).toBe(true);
expect(res.body.requests.movie).toHaveLength(2);
expect(res.body.requests.tv).toHaveLength(2);
expect(res.body.total).toBe(4);
});
it('returns user-filtered requests when admin with showAll=false', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?showAll=false')
.set('Cookie', cookies)
.expect(200);
expect(res.body.user).toBe('AdminUser');
expect(res.body.isAdmin).toBe(true);
expect(res.body.showAll).toBe(false);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
expect(res.body.requests.tv).toHaveLength(1);
expect(res.body.requests.tv[0].requestedUser.userName).toBe('adminuser');
expect(res.body.total).toBe(2);
});
it('returns user-filtered requests when admin without showAll parameter', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.showAll).toBe(false);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
});
it('handles case-insensitive username matching', async () => {
const requestsWithMixedCase = [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
{ id: 2, title: 'Admin Movie', requestedUser: { userName: 'ADMIN' }, requestedByAlias: 'ADMIN', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithMixedCase, []);
const { cookies } = await authenticateUser(app, 'testuser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userName).toBe('TestUser');
});
it('handles missing requestedUser field gracefully', async () => {
const requestsWithMissingUser = [
{ id: 1, title: 'Test Movie', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithMissingUser, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(0);
expect(res.body.total).toBe(0);
});
it('handles empty requests array', async () => {
nock.cleanAll();
setupOmbiRequestMocks([], []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(0);
expect(res.body.requests.tv).toHaveLength(0);
expect(res.body.total).toBe(0);
});
it('handles object-format requestedUser with alias field', async () => {
const requestsWithAlias = [
{ id: 1, title: 'Test Movie', requestedUser: { alias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithAlias, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.alias).toBe('testuser');
});
it('handles object-format requestedUser with userName field', async () => {
const requestsWithUserName = [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithUserName, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser');
});
it('handles object-format requestedUser with userAlias field', async () => {
const requestsWithUserAlias = [
{ id: 1, title: 'Test Movie', requestedUser: { userAlias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithUserAlias, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.userAlias).toBe('testuser');
});
it('handles object-format requestedUser with normalizedUserName field', async () => {
const requestsWithNormalizedUserName = [
{ id: 1, title: 'Test Movie', requestedUser: { normalizedUserName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithNormalizedUserName, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.requests.movie[0].requestedUser.normalizedUserName).toBe('testuser');
});
it('handles requestedUser as null gracefully', async () => {
const requestsWithNullUser = [
{ id: 1, title: 'Test Movie', requestedUser: null, requestedByAlias: 'otheruser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithNullUser, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(0);
expect(res.body.total).toBe(0);
});
it('handles requestedUser as empty object gracefully', async () => {
const requestsWithEmptyObject = [
{ id: 1, title: 'Test Movie', requestedUser: {}, requestedByAlias: 'testuser', type: 'movie' }
];
nock.cleanAll();
setupOmbiRequestMocks(requestsWithEmptyObject, []);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/requests')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(1);
expect(res.body.total).toBe(1);
});
});
// ---------------------------------------------------------------------------
// GET /api/ombi/requests — query param filtering
// ---------------------------------------------------------------------------
const FILTERED_MOVIE_REQUESTS = [
{ id: 1, title: 'The Batman', requestedDate: '2026-05-21T10:00:00.000Z', available: false, approved: true, denied: false, requested: true, theMovieDbId: 414906 },
{ id: 2, title: 'Batman Returns', requestedDate: '2026-05-10T10:00:00.000Z', available: true, approved: true, denied: false, requested: true, theMovieDbId: 414907 }
];
const FILTERED_TV_REQUESTS = [
{ id: 3, title: 'Superman Show', requestedDate: '2026-05-15T10:00:00.000Z', available: false, approved: false, denied: false, requested: true, theMovieDbId: 101 }
];
describe('GET /api/ombi/requests query params', () => {
let app;
beforeEach(() => {
app = makeApp();
setupOmbiRequestMocks(FILTERED_MOVIE_REQUESTS, FILTERED_TV_REQUESTS);
// Reset the singleton registry so it re-initializes on each request
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
});
afterEach(() => {
if (arrRetrieverRegistry) {
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
}
});
it('filters by type=movie', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?type=movie&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie.length, `Body was: ${JSON.stringify(res.body)}`).toBe(2);
expect(res.body.requests.tv).toHaveLength(0);
expect(res.body.total).toBe(2);
});
it('filters by type=tv', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?type=tv&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.requests.movie).toHaveLength(0);
expect(res.body.requests.tv).toHaveLength(1);
expect(res.body.total).toBe(1);
});
it('filters by status=pending', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?status=pending&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.total).toBe(1);
expect(res.body.requests.tv[0].title).toBe('Superman Show');
});
it('filters by status=available', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?status=available&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.total).toBe(1);
expect(res.body.requests.movie[0].title).toBe('Batman Returns');
});
it('sorts by title_asc', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?sort=title_asc&showAll=true')
.set('Cookie', cookies)
.expect(200);
const all = [...res.body.requests.movie, ...res.body.requests.tv];
expect(all.map(r => r.title)).toEqual(['Batman Returns', 'The Batman', 'Superman Show']);
});
it('sorts by title_desc', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?sort=title_desc&showAll=true')
.set('Cookie', cookies)
.expect(200);
const all = [...res.body.requests.movie, ...res.body.requests.tv];
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
});
it('sorts by requestedDate_desc (default)', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?showAll=true')
.set('Cookie', cookies)
.expect(200);
const all = [...res.body.requests.movie, ...res.body.requests.tv];
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
});
it('searches by title substring', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?search=bat&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.total).toBe(2);
expect(res.body.requests.movie).toHaveLength(2);
expect(res.body.requests.tv).toHaveLength(0);
});
it('combines multiple query params', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?type=movie&status=approved&search=bat&sort=title_asc&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.total).toBe(1);
expect(res.body.requests.movie[0].title).toBe('The Batman');
});
it('invalid sort falls back to default', async () => {
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?sort=invalid&showAll=true')
.set('Cookie', cookies)
.expect(200);
expect(res.body.total).toBe(3);
});
});
// ---------------------------------------------------------------------------
// GET /api/ombi/webhook/status
// ---------------------------------------------------------------------------
describe('GET /api/ombi/webhook/status', () => {
let app;
beforeEach(() => {
app = makeApp();
});
it('returns 401 when not authenticated', async () => {
const res = await request(app)
.get('/api/ombi/webhook/status')
.expect(401);
expect(res.body.error).toBe('Not authenticated');
});
it('returns disabled status when SOFARR_BASE_URL is missing', async () => {
delete process.env.SOFARR_BASE_URL;
app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(false);
expect(res.body.webhookUrl).toBeNull();
expect(res.body.applicationToken).toBeNull();
expect(res.body.triggers).toEqual({
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
});
expect(res.body.stats).toBeNull();
});
it('returns disabled status when SOFARR_WEBHOOK_SECRET is missing', async () => {
delete process.env.SOFARR_WEBHOOK_SECRET;
app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(false);
expect(res.body.webhookUrl).toBeNull();
expect(res.body.applicationToken).toBeNull();
expect(res.body.triggers).toEqual({
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
});
expect(res.body.stats).toBeNull();
});
it('returns disabled status when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are missing', async () => {
delete process.env.SOFARR_BASE_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(false);
expect(res.body.webhookUrl).toBeNull();
expect(res.body.applicationToken).toBeNull();
expect(res.body.triggers).toEqual({
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
});
expect(res.body.stats).toBeNull();
});
it('returns disabled status when Ombi not configured', async () => {
process.env.OMBI_INSTANCES = JSON.stringify([]);
app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(false);
expect(res.body.webhookUrl).toBeNull();
expect(res.body.applicationToken).toBeNull();
expect(res.body.triggers).toEqual({
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
});
expect(res.body.stats).toBeNull();
});
it('returns enabled status with triggers when Ombi configured', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, OMBI_WEBHOOK_CONFIG);
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(true);
expect(res.body.webhookUrl).toBe(OMBI_WEBHOOK_CONFIG.webhookUrl);
expect(res.body.applicationToken).toBe(OMBI_WEBHOOK_CONFIG.applicationToken);
expect(res.body.triggers).toEqual({
requestAvailable: true,
requestApproved: true,
requestDeclined: true,
requestPending: true,
requestProcessing: true
});
expect(res.body.stats).toBeNull();
});
it('returns stats when metrics available in cache', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, OMBI_WEBHOOK_CONFIG);
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(OMBI_WEBHOOK_METRICS);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(true);
expect(res.body.stats).toEqual({
eventsReceived: 10,
pollsSkipped: 5,
lastWebhookTimestamp: 1716326400000
});
});
it('returns disabled triggers when webhook disabled in Ombi', async () => {
const disabledConfig = { ...OMBI_WEBHOOK_CONFIG, enabled: false };
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, disabledConfig);
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(false);
expect(res.body.triggers).toEqual({
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
});
});
it('handles Ombi API errors gracefully', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Internal server error' });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(500);
expect(res.body.error).toBe('Failed to fetch Ombi webhook status');
});
it('handles missing webhookUrl and applicationToken in Ombi response', async () => {
const incompleteConfig = { enabled: true };
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, incompleteConfig);
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/ombi/webhook/status')
.set('Cookie', cookies)
.expect(200);
expect(res.body.enabled).toBe(true);
expect(res.body.webhookUrl).toBeNull();
expect(res.body.applicationToken).toBeNull();
});
});
// ---------------------------------------------------------------------------
// POST /api/ombi/webhook/enable
// ---------------------------------------------------------------------------
describe('POST /api/ombi/webhook/enable', () => {
let app;
beforeEach(() => {
app = makeApp();
});
it('returns 403 when not authenticated (CSRF check before auth)', async () => {
const res = await request(app)
.post('/api/ombi/webhook/enable')
.expect(403);
expect(res.body.error).toBe('CSRF token missing');
});
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
delete process.env.SOFARR_BASE_URL;
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
});
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
delete process.env.SOFARR_WEBHOOK_SECRET;
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
});
it('returns 400 when Ombi not configured', async () => {
process.env.OMBI_INSTANCES = JSON.stringify([]);
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('Ombi not configured');
});
it('enables webhook successfully', async () => {
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook')
.reply(200, { success: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
expect(res.body.applicationToken).toBe('test-ombi-key');
});
it('handles Ombi API errors gracefully', async () => {
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Internal server error' });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(500);
expect(res.body.error).toBe('Failed to enable Ombi webhook');
});
});
// ---------------------------------------------------------------------------
// POST /api/ombi/webhook/test
// ---------------------------------------------------------------------------
describe('POST /api/ombi/webhook/test', () => {
let app;
beforeEach(() => {
app = makeApp();
});
it('returns 403 when not authenticated (CSRF check before auth)', async () => {
const res = await request(app)
.post('/api/ombi/webhook/test')
.expect(403);
expect(res.body.error).toBe('CSRF token missing');
});
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
delete process.env.SOFARR_BASE_URL;
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
});
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
delete process.env.SOFARR_WEBHOOK_SECRET;
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
});
it('returns 400 when Ombi not configured', async () => {
process.env.OMBI_INSTANCES = JSON.stringify([]);
app = createApp({ skipRateLimits: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(400);
expect(res.body.error).toBe('Ombi not configured');
});
it('sends test webhook successfully', async () => {
nock(SOFARR_BASE)
.post('/api/webhook/ombi')
.reply(200, { received: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
expect(res.body.success).toBe(true);
});
it('sends test webhook with correct payload', async () => {
const webhookScope = nock(SOFARR_BASE)
.post('/api/webhook/ombi')
.reply(200, { received: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
// Verify the request was made with correct headers and payload
expect(webhookScope.isDone()).toBe(true);
});
it('handles webhook send errors gracefully', async () => {
nock(SOFARR_BASE)
.post('/api/webhook/ombi')
.reply(500, { error: 'Internal server error' });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/test')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(500);
expect(res.body.error).toBe('Failed to test Ombi webhook');
});
});
@@ -142,6 +142,10 @@ describe('Swagger Coverage', () => {
expect(paths['/api/webhook/sonarr'].post).toBeDefined();
expect(paths['/api/webhook/radarr']).toBeDefined();
expect(paths['/api/webhook/radarr'].post).toBeDefined();
expect(paths['/api/webhook/ombi']).toBeDefined();
expect(paths['/api/webhook/ombi'].post).toBeDefined();
expect(paths['/api/webhook/config']).toBeDefined();
expect(paths['/api/webhook/config'].get).toBeDefined();
});
it('should have Sonarr proxy endpoints documented', () => {
@@ -179,6 +183,19 @@ describe('Swagger Coverage', () => {
expect(paths['/api/emby/session/{sessionId}/user']).toBeDefined();
});
it('should have Ombi endpoints documented', () => {
const paths = openapiSpec.paths;
expect(paths['/api/ombi/requests']).toBeDefined();
expect(paths['/api/ombi/requests'].get).toBeDefined();
expect(paths['/api/ombi/webhook/enable']).toBeDefined();
expect(paths['/api/ombi/webhook/enable'].post).toBeDefined();
expect(paths['/api/ombi/webhook/status']).toBeDefined();
expect(paths['/api/ombi/webhook/status'].get).toBeDefined();
expect(paths['/api/ombi/webhook/test']).toBeDefined();
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
});
it('should return 200 for Swagger UI endpoint', async () => {
const response = await request(app).get('/api/swagger').redirects(1);
expect(response.status).toBe(200);
+125
View File
@@ -28,6 +28,18 @@ const require = createRequire(import.meta.url);
const cache = require('../../server/utils/cache.js');
const VALID_SECRET = 'test-webhook-secret-abc';
const EMBY_BASE = 'https://emby.test';
const EMBY_AUTH_BODY = {
AccessToken: 'test-emby-token-abc123',
User: { Id: 'user-id-001', Name: 'TestUser' }
};
const EMBY_USER_BODY = {
Id: 'user-id-001',
Name: 'TestUser',
Policy: { IsAdministrator: false }
};
// Minimal valid Sonarr Grab payload
const SONARR_GRAB = {
@@ -53,7 +65,31 @@ const SONARR_TEST = {
date: '2026-05-19T10:00:02.000Z'
};
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
nock(EMBY_BASE)
.post('/Users/authenticatebyname')
.reply(200, EMBY_AUTH_BODY);
nock(EMBY_BASE)
.get(/\/Users\//)
.reply(200, userBody);
}
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
const userBody = isAdmin ? { ...EMBY_USER_BODY, Policy: { IsAdministrator: true } } : EMBY_USER_BODY;
interceptSuccessfulLogin(userBody);
const res = await request(app)
.post('/api/auth/login')
.send({ username, password: 'password' });
const cookies = res.headers['set-cookie'];
const csrfToken = res.body.csrfToken;
return { cookies, csrfToken };
}
function makeApp() {
process.env.EMBY_URL = EMBY_BASE;
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' }
@@ -84,7 +120,11 @@ beforeEach(() => {
afterEach(() => {
nock.cleanAll();
delete process.env.EMBY_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
delete process.env.SOFARR_BASE_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
// ---------------------------------------------------------------------------
@@ -393,3 +433,88 @@ describe('Security — secret never leaks', () => {
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
});
});
// ---------------------------------------------------------------------------
// GET /api/webhook/config
// ---------------------------------------------------------------------------
describe('GET /api/webhook/config', () => {
it('returns 401 when not authenticated', async () => {
const app = makeApp();
const res = await request(app)
.get('/api/webhook/config')
.expect(401);
expect(res.body.error).toBe('Not authenticated');
});
it('returns valid: true when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured', async () => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
const app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/webhook/config')
.set('Cookie', cookies)
.expect(200);
expect(res.body.valid).toBe(true);
expect(res.body.missing).toEqual([]);
});
it('returns valid: false when SOFARR_BASE_URL is missing', async () => {
process.env.EMBY_URL = EMBY_BASE;
delete process.env.SOFARR_BASE_URL;
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
const app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/webhook/config')
.set('Cookie', cookies)
.expect(200);
expect(res.body.valid).toBe(false);
expect(res.body.missing).toEqual(['SOFARR_BASE_URL']);
});
it('returns valid: false when SOFARR_WEBHOOK_SECRET is missing', async () => {
process.env.EMBY_URL = EMBY_BASE;
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
delete process.env.SOFARR_WEBHOOK_SECRET;
const app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/webhook/config')
.set('Cookie', cookies)
.expect(200);
expect(res.body.valid).toBe(false);
expect(res.body.missing).toEqual(['SOFARR_WEBHOOK_SECRET']);
});
it('returns valid: false when both are missing', async () => {
process.env.EMBY_URL = EMBY_BASE;
delete process.env.SOFARR_BASE_URL;
delete process.env.SOFARR_WEBHOOK_SECRET;
const app = createApp({ skipRateLimits: true });
const { cookies } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.get('/api/webhook/config')
.set('Cookie', cookies)
.expect(200);
expect(res.body.valid).toBe(false);
expect(res.body.missing).toContain('SOFARR_BASE_URL');
expect(res.body.missing).toContain('SOFARR_WEBHOOK_SECRET');
expect(res.body.missing).toHaveLength(2);
});
});
@@ -0,0 +1,198 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nock from 'nock';
import PollingRadarrRetriever from '../../../server/clients/PollingRadarrRetriever';
describe('PollingRadarrRetriever', () => {
const config = {
id: 'radarr-test',
name: 'Test Radarr',
url: 'http://radarr-mock.test',
apiKey: 'mock-api-key'
};
let retriever;
beforeEach(() => {
retriever = new PollingRadarrRetriever(config);
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
it('should return correct type and instance ID', () => {
expect(retriever.getRetrieverType()).toBe('radarr');
expect(retriever.getInstanceId()).toBe('radarr-test');
});
describe('getTags', () => {
it('should fetch tags successfully', async () => {
const mockTags = [{ id: 1, label: 'tag1' }, { id: 2, label: 'tag2' }];
nock(config.url)
.get('/api/v3/tag')
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockTags);
const tags = await retriever.getTags();
expect(tags).toEqual(mockTags);
});
it('should return an empty array on error and log it', async () => {
nock(config.url)
.get('/api/v3/tag')
.reply(500, 'Internal Server Error');
const tags = await retriever.getTags();
expect(tags).toEqual([]);
});
});
describe('getQueue', () => {
it('should fetch queue in a single page if records count is less than 1000', async () => {
const mockQueueResponse = {
page: 1,
pageSize: 1000,
totalRecords: 2,
records: [
{ id: 1, title: 'Movie 1' },
{ id: 2, title: 'Movie 2' }
]
};
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 1, pageSize: 1000 })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockQueueResponse);
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(2);
expect(queue.records).toEqual(mockQueueResponse.records);
});
it('should paginate queue if the page size is exactly 1000', async () => {
const page1Records = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Movie ${i}` }));
const page2Records = [{ id: 1000, title: 'Movie 1000' }];
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 1, pageSize: 1000 })
.reply(200, {
page: 1,
pageSize: 1000,
totalRecords: 1001,
records: page1Records
});
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 2, pageSize: 1000 })
.reply(200, {
page: 2,
pageSize: 1000,
totalRecords: 1001,
records: page2Records
});
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(1001);
expect(queue.records[1000]).toEqual(page2Records[0]);
});
it('should throw an error if the request fails', async () => {
nock(config.url)
.get('/api/v3/queue')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getQueue()).rejects.toThrow();
});
});
describe('getHistory', () => {
it('should fetch history with default parameters', async () => {
const mockHistoryResponse = {
page: 1,
pageSize: 100,
totalRecords: 2,
records: [
{ id: 1, eventType: 'grabbed' },
{ id: 2, eventType: 'downloadFolderImported' }
]
};
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 100, includeMovie: 'true' })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory();
expect(history.records).toHaveLength(2);
expect(history.records).toEqual(mockHistoryResponse.records);
});
it('should apply sorting and startDate filters from options', async () => {
const mockHistoryResponse = { page: 1, pageSize: 10, totalRecords: 0, records: [] };
nock(config.url)
.get('/api/v3/history')
.query({
page: 1,
pageSize: 10,
includeMovie: 'false',
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
})
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory({
pageSize: 10,
includeMovie: false,
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
});
expect(history.records).toEqual([]);
});
it('should paginate history when more pages are available up to maxPages', async () => {
const page1Records = Array.from({ length: 50 }, (_, i) => ({ id: i }));
const page2Records = Array.from({ length: 50 }, (_, i) => ({ id: 50 + i }));
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 50, includeMovie: 'true' })
.reply(200, {
page: 1,
pageSize: 50,
records: page1Records
});
nock(config.url)
.get('/api/v3/history')
.query({ page: 2, pageSize: 50, includeMovie: 'true' })
.reply(200, {
page: 2,
pageSize: 50,
records: page2Records
});
const history = await retriever.getHistory({ pageSize: 50, maxPages: 2 });
expect(history.records).toHaveLength(100);
});
it('should throw an error on API failure', async () => {
nock(config.url)
.get('/api/v3/history')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getHistory()).rejects.toThrow();
});
});
});
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
import PollingSonarrRetriever from '../../../server/clients/PollingSonarrRetriever';
describe('PollingSonarrRetriever', () => {
const config = {
id: 'sonarr-test',
name: 'Test Sonarr',
url: 'http://sonarr-mock.test',
apiKey: 'mock-api-key'
};
let retriever;
beforeEach(() => {
retriever = new PollingSonarrRetriever(config);
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
it('should return correct type and instance ID', () => {
expect(retriever.getRetrieverType()).toBe('sonarr');
expect(retriever.getInstanceId()).toBe('sonarr-test');
});
describe('getTags', () => {
it('should fetch tags successfully', async () => {
const mockTags = [{ id: 1, label: 'tag1' }, { id: 2, label: 'tag2' }];
nock(config.url)
.get('/api/v3/tag')
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockTags);
const tags = await retriever.getTags();
expect(tags).toEqual(mockTags);
});
it('should return an empty array on error and log it', async () => {
nock(config.url)
.get('/api/v3/tag')
.reply(500, 'Internal Server Error');
const tags = await retriever.getTags();
expect(tags).toEqual([]);
});
});
describe('getQueue', () => {
it('should fetch queue in a single page if records count is less than 1000', async () => {
const mockQueueResponse = {
page: 1,
pageSize: 1000,
totalRecords: 2,
records: [
{ id: 1, title: 'Item 1' },
{ id: 2, title: 'Item 2' }
]
};
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 1, pageSize: 1000 })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockQueueResponse);
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(2);
expect(queue.records).toEqual(mockQueueResponse.records);
});
it('should paginate queue if the page size is exactly 1000', async () => {
const page1Records = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Item ${i}` }));
const page2Records = [{ id: 1000, title: 'Item 1000' }];
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 1, pageSize: 1000 })
.reply(200, {
page: 1,
pageSize: 1000,
totalRecords: 1001,
records: page1Records
});
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 2, pageSize: 1000 })
.reply(200, {
page: 2,
pageSize: 1000,
totalRecords: 1001,
records: page2Records
});
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(1001);
expect(queue.records[1000]).toEqual(page2Records[0]);
});
it('should throw an error if the request fails', async () => {
nock(config.url)
.get('/api/v3/queue')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getQueue()).rejects.toThrow();
});
});
describe('getHistory', () => {
it('should fetch history with default parameters', async () => {
const mockHistoryResponse = {
page: 1,
pageSize: 100,
totalRecords: 2,
records: [
{ id: 1, eventType: 'grabbed' },
{ id: 2, eventType: 'downloadFolderImported' }
]
};
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 100, includeSeries: 'true', includeEpisode: 'true' })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory();
expect(history.records).toHaveLength(2);
expect(history.records).toEqual(mockHistoryResponse.records);
});
it('should apply sorting and startDate filters from options', async () => {
const mockHistoryResponse = { page: 1, pageSize: 10, totalRecords: 0, records: [] };
nock(config.url)
.get('/api/v3/history')
.query({
page: 1,
pageSize: 10,
includeSeries: 'false',
includeEpisode: 'false',
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
})
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory({
pageSize: 10,
includeSeries: false,
includeEpisode: false,
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
});
expect(history.records).toEqual([]);
});
it('should paginate history when more pages are available up to maxPages', async () => {
const page1Records = Array.from({ length: 50 }, (_, i) => ({ id: i }));
const page2Records = Array.from({ length: 50 }, (_, i) => ({ id: 50 + i }));
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 50, includeSeries: 'true', includeEpisode: 'true' })
.reply(200, {
page: 1,
pageSize: 50,
records: page1Records
});
nock(config.url)
.get('/api/v3/history')
.query({ page: 2, pageSize: 50, includeSeries: 'true', includeEpisode: 'true' })
.reply(200, {
page: 2,
pageSize: 50,
records: page2Records
});
const history = await retriever.getHistory({ pageSize: 50, maxPages: 2 });
expect(history.records).toHaveLength(100);
});
it('should throw an error on API failure', async () => {
nock(config.url)
.get('/api/v3/history')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getHistory()).rejects.toThrow();
});
});
});
-492
View File
@@ -1,492 +0,0 @@
// 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([]);
});
});
+2
View File
@@ -219,11 +219,13 @@ describe('DownloadClientRegistry', () => {
});
it('should get downloads grouped by client type', async () => {
const qbClient = testRegistry.getClient('qb1');
const downloadsByType = await testRegistry.getDownloadsByClientType();
expect(downloadsByType.sabnzbd).toHaveLength(1);
expect(downloadsByType.qbittorrent).toHaveLength(1);
expect(downloadsByType.transmission).toBeUndefined();
expect(qbClient.resetFallbackFlag).toHaveBeenCalled();
});
it('should handle client errors gracefully', async () => {
+235
View File
@@ -0,0 +1,235 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect } from 'vitest';
const {
getRequestStatus,
filterByType,
filterByStatus,
filterBySearch,
sortRequests,
applyRequestFilters
} = require('../../server/utils/ombiFilters');
function makeRequest(overrides = {}) {
return {
id: 1,
title: 'Test Request',
requestedDate: '2026-05-21T10:00:00.000Z',
available: false,
approved: false,
denied: false,
requested: true,
mediaType: 'movie',
...overrides
};
}
// ---------------------------------------------------------------------------
// getRequestStatus
// ---------------------------------------------------------------------------
describe('getRequestStatus', () => {
it('returns available when available is true', () => {
expect(getRequestStatus(makeRequest({ available: true }))).toBe('available');
});
it('returns denied when denied is true', () => {
expect(getRequestStatus(makeRequest({ denied: true }))).toBe('denied');
});
it('returns approved when approved is true', () => {
expect(getRequestStatus(makeRequest({ approved: true }))).toBe('approved');
});
it('returns pending when requested is true', () => {
expect(getRequestStatus(makeRequest({ requested: true }))).toBe('pending');
});
it('returns unknown for empty object', () => {
expect(getRequestStatus({})).toBe('unknown');
});
it('returns unknown for null', () => {
expect(getRequestStatus(null)).toBe('unknown');
});
it('follows priority: available > denied > approved > pending', () => {
expect(getRequestStatus(makeRequest({ available: true, denied: true }))).toBe('available');
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
});
});
// ---------------------------------------------------------------------------
// filterByType
// ---------------------------------------------------------------------------
describe('filterByType', () => {
const movie = makeRequest({ mediaType: 'movie' });
const tv = makeRequest({ mediaType: 'tv', id: 2 });
it('returns all when types is empty', () => {
expect(filterByType([movie, tv], [])).toEqual([movie, tv]);
});
it('returns all when types includes "all"', () => {
expect(filterByType([movie, tv], ['all'])).toEqual([movie, tv]);
});
it('filters to movies only', () => {
expect(filterByType([movie, tv], ['movie'])).toEqual([movie]);
});
it('filters to tv only', () => {
expect(filterByType([movie, tv], ['tv'])).toEqual([tv]);
});
it('is case-insensitive', () => {
expect(filterByType([movie, tv], ['MOVIE'])).toEqual([movie]);
});
it('handles empty array', () => {
expect(filterByType([], ['movie'])).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// filterByStatus
// ---------------------------------------------------------------------------
describe('filterByStatus', () => {
const pending = makeRequest({ requested: true });
const approved = makeRequest({ approved: true, requested: true, id: 2 });
const available = makeRequest({ available: true, id: 3 });
it('returns all when statuses is empty', () => {
expect(filterByStatus([pending, approved], [])).toEqual([pending, approved]);
});
it('filters by single status', () => {
expect(filterByStatus([pending, approved], ['approved'])).toEqual([approved]);
});
it('filters by multiple statuses', () => {
expect(filterByStatus([pending, approved, available], ['pending', 'available'])).toEqual([pending, available]);
});
it('is case-insensitive', () => {
expect(filterByStatus([pending], ['PENDING'])).toEqual([pending]);
});
it('handles empty array', () => {
expect(filterByStatus([], ['pending'])).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// filterBySearch
// ---------------------------------------------------------------------------
describe('filterBySearch', () => {
const batman = makeRequest({ title: 'The Batman' });
const superman = makeRequest({ title: 'Superman', id: 2 });
it('returns all when query is empty', () => {
expect(filterBySearch([batman, superman], '')).toEqual([batman, superman]);
});
it('returns all when query is whitespace', () => {
expect(filterBySearch([batman, superman], ' ')).toEqual([batman, superman]);
});
it('filters by case-insensitive substring', () => {
expect(filterBySearch([batman, superman], 'bat')).toEqual([batman]);
expect(filterBySearch([batman, superman], 'BAT')).toEqual([batman]);
});
it('handles missing title', () => {
expect(filterBySearch([makeRequest({ title: undefined })], 'test')).toEqual([]);
});
it('handles empty array', () => {
expect(filterBySearch([], 'test')).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// sortRequests
// ---------------------------------------------------------------------------
describe('sortRequests', () => {
const oldReq = makeRequest({ id: 1, title: 'Alpha', requestedDate: '2026-01-01T00:00:00.000Z' });
const midReq = makeRequest({ id: 2, title: 'Beta', requestedDate: '2026-05-01T00:00:00.000Z' });
const newReq = makeRequest({ id: 3, title: 'Charlie', requestedDate: '2026-10-01T00:00:00.000Z' });
it('sorts newest to oldest by default', () => {
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_desc');
expect(sorted.map(r => r.id)).toEqual([3, 2, 1]);
});
it('sorts oldest to newest', () => {
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_asc');
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
});
it('sorts A-Z', () => {
const sorted = sortRequests([midReq, oldReq, newReq], 'title_asc');
expect(sorted.map(r => r.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
});
it('sorts Z-A', () => {
const sorted = sortRequests([midReq, oldReq, newReq], 'title_desc');
expect(sorted.map(r => r.title)).toEqual(['Charlie', 'Beta', 'Alpha']);
});
it('defaults to requestedDate_desc for unknown sort mode', () => {
const sorted = sortRequests([oldReq, newReq], 'invalid');
expect(sorted.map(r => r.id)).toEqual([3, 1]);
});
it('handles missing requestedDate by treating as epoch 0', () => {
const noDate = makeRequest({ id: 4, requestedDate: undefined });
const sorted = sortRequests([midReq, noDate], 'requestedDate_desc');
expect(sorted[0]).toBe(midReq);
expect(sorted[1]).toBe(noDate);
});
it('handles missing title', () => {
const noTitle = makeRequest({ id: 4, title: undefined });
const withTitle = makeRequest({ id: 5, title: 'Zebra' });
const sorted = sortRequests([noTitle, withTitle], 'title_asc');
expect(sorted[0]).toBe(noTitle);
expect(sorted[1]).toBe(withTitle);
});
});
// ---------------------------------------------------------------------------
// applyRequestFilters
// ---------------------------------------------------------------------------
describe('applyRequestFilters', () => {
const moviePending = makeRequest({ id: 1, title: 'The Batman', mediaType: 'movie', requested: true, approved: false });
const tvApproved = makeRequest({ id: 2, title: 'Superman Show', mediaType: 'tv', approved: true, requested: false });
const movieAvailable = makeRequest({ id: 3, title: 'Batman Returns', mediaType: 'movie', available: true });
it('applies all filters together', () => {
const result = applyRequestFilters(
[moviePending, tvApproved, movieAvailable],
{ types: ['movie'], statuses: ['pending', 'available'], sort: 'title_asc', search: 'bat' }
);
expect(result.map(r => r.id)).toEqual([3, 1]);
});
it('returns unfiltered when no options provided', () => {
const result = applyRequestFilters([moviePending, tvApproved], {});
expect(result).toEqual([moviePending, tvApproved]);
});
it('returns empty array when no matches', () => {
const result = applyRequestFilters(
[moviePending],
{ types: ['tv'] }
);
expect(result).toEqual([]);
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect } from 'vitest';
const {
extractRequestedUser,
filterRequestsByUser
} = require('../../server/utils/ombiHelpers');
describe('ombiHelpers', () => {
describe('extractRequestedUser', () => {
it('returns empty string if request is null or undefined', () => {
expect(extractRequestedUser(null)).toBe('');
expect(extractRequestedUser(undefined)).toBe('');
});
it('returns requestedUser if requestedUser is a string', () => {
const req = { requestedUser: 'testuser', requestedByAlias: 'alias' };
expect(extractRequestedUser(req)).toBe('testuser');
});
it('falls back to requestedByAlias if requestedUser is missing', () => {
const req = { requestedByAlias: 'aliasuser' };
expect(extractRequestedUser(req)).toBe('aliasuser');
});
it('returns alias from requestedUser object if present', () => {
const req = {
requestedUser: {
alias: 'alias_val',
userAlias: 'userAlias_val',
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('alias_val');
});
it('returns userAlias from requestedUser object if alias is missing', () => {
const req = {
requestedUser: {
userAlias: 'userAlias_val',
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('userAlias_val');
});
it('returns userName from requestedUser object if alias/userAlias are missing', () => {
const req = {
requestedUser: {
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('userName_val');
});
it('returns normalizedUserName from requestedUser object if other fields are missing', () => {
const req = {
requestedUser: {
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('normalized_val');
});
it('falls back to requestedByAlias when requestedUser is empty object {} (bug fix)', () => {
const req = {
requestedUser: {},
requestedByAlias: 'fallback_alias'
};
expect(extractRequestedUser(req)).toBe('fallback_alias');
});
it('returns empty string if requestedUser is empty object {} and requestedByAlias is missing', () => {
const req = {
requestedUser: {}
};
expect(extractRequestedUser(req)).toBe('');
});
});
describe('filterRequestsByUser', () => {
const movie1 = { id: 1, requestedUser: { userName: 'user1' }, type: 'movie' };
const movie2 = { id: 2, requestedUser: { userName: 'user2' }, type: 'movie' };
const tv1 = { id: 3, requestedUser: { alias: 'User1' }, type: 'tv' };
it('returns empty array if requests input is not an array', () => {
expect(filterRequestsByUser(null, 'user1', false)).toEqual([]);
expect(filterRequestsByUser({}, 'user1', false)).toEqual([]);
});
it('returns all requests unmodified if showAll is true', () => {
const requests = [movie1, movie2];
expect(filterRequestsByUser(requests, 'user1', true)).toEqual(requests);
});
it('returns all requests unmodified if username is falsy or missing', () => {
const requests = [movie1, movie2];
expect(filterRequestsByUser(requests, '', false)).toEqual(requests);
expect(filterRequestsByUser(requests, null, false)).toEqual(requests);
});
it('filters requests correctly for a specific user', () => {
const requests = [movie1, movie2, tv1];
const result = filterRequestsByUser(requests, 'user1', false);
expect(result).toHaveLength(2);
expect(result).toContainEqual(movie1);
expect(result).toContainEqual(tv1);
expect(result).not.toContainEqual(movie2);
});
it('performs case-insensitive filtering', () => {
const requests = [movie1, movie2, tv1];
const result = filterRequestsByUser(requests, 'USER1', false);
expect(result).toHaveLength(2);
expect(result).toContainEqual(movie1);
expect(result).toContainEqual(tv1);
});
});
});
@@ -753,77 +753,5 @@ describe('DownloadAssembler', () => {
});
});
describe('getOmbiLink', () => {
it('returns correct URL for valid requestId, type, and baseUrl', () => {
const result = DownloadAssembler.getOmbiLink(123, 'movie', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/request/movie/123');
});
it('returns correct URL for TV type', () => {
const result = DownloadAssembler.getOmbiLink(456, 'tv', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/request/tv/456');
});
it('returns null when requestId is missing', () => {
const result = DownloadAssembler.getOmbiLink(null, 'movie', 'http://localhost:5000');
expect(result).toBeNull();
});
it('returns null when type is missing', () => {
const result = DownloadAssembler.getOmbiLink(123, null, 'http://localhost:5000');
expect(result).toBeNull();
});
it('returns null when baseUrl is missing', () => {
const result = DownloadAssembler.getOmbiLink(123, 'movie', null);
expect(result).toBeNull();
});
it('returns null when all parameters are missing', () => {
const result = DownloadAssembler.getOmbiLink(null, null, null);
expect(result).toBeNull();
});
it('handles string requestId', () => {
const result = DownloadAssembler.getOmbiLink('abc-123', 'movie', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/request/movie/abc-123');
});
});
describe('getOmbiSearchLink', () => {
it('returns correct URL for series type', () => {
const result = DownloadAssembler.getOmbiSearchLink(789, 'series', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/tv/search/789');
});
it('returns correct URL for movie type', () => {
const result = DownloadAssembler.getOmbiSearchLink(101, 'movie', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/movie/search/101');
});
it('returns null when searchId is missing', () => {
const result = DownloadAssembler.getOmbiSearchLink(null, 'series', 'http://localhost:5000');
expect(result).toBeNull();
});
it('returns null when type is missing', () => {
const result = DownloadAssembler.getOmbiSearchLink(789, null, 'http://localhost:5000');
expect(result).toBeNull();
});
it('returns null when baseUrl is missing', () => {
const result = DownloadAssembler.getOmbiSearchLink(789, 'series', null);
expect(result).toBeNull();
});
it('returns null for invalid type', () => {
const result = DownloadAssembler.getOmbiSearchLink(789, 'invalid', 'http://localhost:5000');
expect(result).toBeNull();
});
it('handles string searchId', () => {
const result = DownloadAssembler.getOmbiSearchLink('search-123', 'movie', 'http://localhost:5000');
expect(result).toBe('http://localhost:5000/#/movie/search/search-123');
});
});
});
+32 -252
View File
@@ -1,6 +1,5 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import nock from 'nock';
// Mock dependencies
vi.mock('../../../server/utils/logger', () => ({
@@ -9,297 +8,78 @@ vi.mock('../../../server/utils/logger', () => ({
// Import after mocking
const DownloadMatcher = require('../../../server/services/DownloadMatcher');
const OmbiRetriever = require('../../../server/clients/OmbiRetriever');
describe('DownloadMatcher', () => {
const ombiBaseUrl = 'http://localhost:5000';
beforeEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
afterEach(() => {
nock.cleanAll();
});
describe('addOmbiMatching', () => {
it('should return early when ombiRetriever is missing', async () => {
it('should return early when ombiBaseUrl is missing', () => {
const downloadObj = { type: 'series', title: 'Test Show' };
const series = { tvdbId: '12345', tmdbId: '67890' };
const context = { ombiRetriever: null, ombiBaseUrl };
const context = { ombiBaseUrl: null };
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
expect(downloadObj.ombiLink).toBeUndefined();
expect(downloadObj.ombiRequestId).toBeUndefined();
expect(downloadObj.ombiTooltip).toBeUndefined();
});
it('should return early when ombiBaseUrl is missing', async () => {
it('should return early when seriesOrMovie is missing', () => {
const downloadObj = { type: 'series', title: 'Test Show' };
const series = { tvdbId: '12345', tmdbId: '67890' };
const context = { ombiBaseUrl };
const mockRetriever = {
findTvRequest: vi.fn(),
searchTv: vi.fn()
};
DownloadMatcher.addOmbiMatching(downloadObj, null, context);
const context = { ombiRetriever: mockRetriever, ombiBaseUrl: null };
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
expect(downloadObj.ombiLink).toBeUndefined();
expect(downloadObj.ombiTooltip).toBeUndefined();
});
it('should return early when seriesOrMovie is missing', async () => {
it('should add ombiLink for series with TMDB ID', () => {
const downloadObj = { type: 'series', title: 'Test Show' };
const series = { tmdbId: '67890' };
const context = { ombiBaseUrl };
const mockRetriever = {
findTvRequest: vi.fn(),
searchTv: vi.fn()
};
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, null, context);
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
expect(downloadObj.ombiLink).toBeUndefined();
expect(downloadObj.ombiLink).toBe('http://localhost:5000/details/tv/67890');
expect(downloadObj.ombiTooltip).toBe('View in Ombi');
});
it('should add ombiLink and ombiRequestId for TV request found by TVDB ID', async () => {
const mockMovieRequests = [];
const mockTvRequests = [
{ id: 101, title: 'Test Show', type: 'tv', theTvDbId: '12345' }
];
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
const downloadObj = { type: 'series', title: 'Test Show' };
const series = { tvdbId: '12345', tmdbId: '67890' };
const context = { ombiRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/101');
expect(downloadObj.ombiRequestId).toBe(101);
expect(downloadObj.ombiTooltip).toBe('Request');
});
it('should add ombiLink and ombiRequestId for TV request found by TMDB ID fallback', async () => {
const mockMovieRequests = [];
const mockTvRequests = [
{ id: 102, title: 'Test Show TMDB', type: 'tv', theMovieDbId: '67890' }
];
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
const downloadObj = { type: 'series', title: 'Test Show TMDB' };
const series = { tvdbId: '99999', tmdbId: '67890' };
const context = { ombiRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/102');
expect(downloadObj.ombiRequestId).toBe(102);
expect(downloadObj.ombiTooltip).toBe('Request');
});
it('should add ombiLink and ombiRequestId for movie request found by TMDB ID', async () => {
const mockMovieRequests = [
{ id: 201, title: 'Test Movie', type: 'movie', theMovieDbId: '54321' }
];
const mockTvRequests = [];
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
it('should add ombiLink for movie with TMDB ID', () => {
const downloadObj = { type: 'movie', title: 'Test Movie' };
const movie = { tmdbId: '54321', imdbId: 'tt54321' };
const context = { ombiRetriever, ombiBaseUrl };
const movie = { tmdbId: '54321' };
const context = { ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/201');
expect(downloadObj.ombiRequestId).toBe(201);
expect(downloadObj.ombiTooltip).toBe('Request');
expect(downloadObj.ombiLink).toBe('http://localhost:5000/details/movie/54321');
expect(downloadObj.ombiTooltip).toBe('View in Ombi');
});
it('should add ombiLink and ombiRequestId for movie request found by IMDB ID fallback', async () => {
const mockMovieRequests = [
{ id: 202, title: 'Test Movie IMDB', type: 'movie', imdbId: 'tt98765' }
];
const mockTvRequests = [];
it('should not add ombiLink when TMDB ID is missing', () => {
const downloadObj = { type: 'series', title: 'Test Show' };
const series = { tvdbId: '12345' };
const context = { ombiBaseUrl };
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
const downloadObj = { type: 'movie', title: 'Test Movie IMDB' };
const movie = { tmdbId: '99999', imdbId: 'tt98765' };
const context = { ombiRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/202');
expect(downloadObj.ombiRequestId).toBe(202);
expect(downloadObj.ombiTooltip).toBe('Request');
});
it('should add search link and tooltip when no request found but search succeeds', async () => {
const mockMovieRequests = [];
const mockTvRequests = [];
const mockSearchResult = { id: 12345, title: 'Test Show Search', theTvDbId: '11111' };
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
nock(ombiBaseUrl)
.get('/api/v1/Search/tv/11111')
.reply(200, mockSearchResult);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
const downloadObj = { type: 'series', title: 'Test Show Search' };
const series = { tvdbId: '11111', tmdbId: '22222' };
const context = { ombiRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/tv/search/12345');
expect(downloadObj.ombiTooltip).toBe('Search');
expect(downloadObj.ombiRequestId).toBeUndefined();
});
it('should add movie search link for movie type', async () => {
const mockMovieRequests = [];
const mockTvRequests = [];
const mockSearchResult = { id: 54321, title: 'Test Movie Search', theMovieDbId: '33333' };
nock(ombiBaseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovieRequests);
nock(ombiBaseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvRequests);
nock(ombiBaseUrl)
.get('/api/v1/Search/movie/33333')
.reply(200, mockSearchResult);
const ombiRetriever = new OmbiRetriever({
id: 'test-ombi',
name: 'Test Ombi',
url: ombiBaseUrl,
apiKey: 'test-key'
});
const downloadObj = { type: 'movie', title: 'Test Movie Search' };
const movie = { tmdbId: '33333', imdbId: 'tt33333' };
const context = { ombiRetriever, ombiBaseUrl };
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/movie/search/54321');
expect(downloadObj.ombiTooltip).toBe('Search');
expect(downloadObj.ombiRequestId).toBeUndefined();
});
it('should handle errors gracefully without breaking download object', async () => {
const mockRetriever = {
findTvRequest: vi.fn().mockRejectedValue(new Error('Ombi API error')),
searchTv: vi.fn().mockRejectedValue(new Error('Search error'))
};
const downloadObj = { type: 'series', title: 'Test Show Error' };
const series = { tvdbId: '66666', tmdbId: '77777' };
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
// Should not throw error
await expect(DownloadMatcher.addOmbiMatching(downloadObj, series, context)).resolves.not.toThrow();
// Download object should still have original data
expect(downloadObj.title).toBe('Test Show Error');
expect(downloadObj.ombiLink).toBeUndefined();
expect(downloadObj.ombiRequestId).toBeUndefined();
expect(downloadObj.ombiTooltip).toBeUndefined();
});
it('should do nothing for unknown download type', async () => {
const mockRetriever = {
findTvRequest: vi.fn(),
findMovieRequest: vi.fn()
};
it('should not add ombiLink for unknown download type', () => {
const downloadObj = { type: 'unknown', title: 'Test Unknown' };
const series = { tmdbId: '67890' };
const context = { ombiBaseUrl };
const downloadObj = { type: 'unknown', title: 'Unknown Type' };
const media = { id: 123 };
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
await DownloadMatcher.addOmbiMatching(downloadObj, media, context);
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
expect(mockRetriever.findMovieRequest).not.toHaveBeenCalled();
expect(downloadObj.ombiLink).toBeUndefined();
expect(downloadObj.ombiTooltip).toBeUndefined();
});
});
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import loadSecrets from '../../../server/utils/loadSecrets';
describe('loadSecrets utility', () => {
let originalEnv;
let exitSpy;
beforeEach(() => {
originalEnv = { ...process.env };
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
vi.spyOn(fs, 'readFileSync');
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
it('does nothing if no _FILE env variables are set', () => {
// Ensure mappings are not in env
delete process.env.COOKIE_SECRET_FILE;
delete process.env.COOKIE_SECRET;
loadSecrets();
expect(fs.readFileSync).not.toHaveBeenCalled();
expect(process.env.COOKIE_SECRET).toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
});
it('loads secrets successfully from a valid file', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockReturnValue(' super_secret_value \n');
loadSecrets();
expect(fs.readFileSync).toHaveBeenCalledWith('/path/to/cookie_secret', 'utf8');
expect(process.env.COOKIE_SECRET).toBe('super_secret_value');
expect(exitSpy).not.toHaveBeenCalled();
});
it('logs a warning if both standard env and _FILE env are set', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
process.env.COOKIE_SECRET = 'existing_value';
vi.mocked(fs.readFileSync).mockReturnValue('new_value');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadSecrets();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Both COOKIE_SECRET and COOKIE_SECRET_FILE are set')
);
expect(process.env.COOKIE_SECRET).toBe('new_value');
expect(exitSpy).not.toHaveBeenCalled();
});
it('logs a warning and skips loading if file is empty', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockReturnValue(' \n ');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadSecrets();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('COOKIE_SECRET_FILE points to an empty file')
);
expect(process.env.COOKIE_SECRET).toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
});
it('exits with status 1 if file reading fails', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
loadSecrets();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to read COOKIE_SECRET_FILE')
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
+9
View File
@@ -81,5 +81,14 @@ describe('verifyCsrf middleware', () => {
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token invalid');
});
it('blocks when tokens have same character length but different byte lengths (multi-byte)', () => {
const next = vi.fn();
const res = makeRes();
verifyCsrf(makeReq('POST', 'cafe\u0301', 'cafes'), res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error).toBe('CSRF token invalid');
expect(next).not.toHaveBeenCalled();
});
});
});