merge branch 'develop' into 'main' - Release v1.7.3
This commit is contained in:
@@ -10,3 +10,5 @@ data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.agents/
|
||||
.windsurf/
|
||||
+94
-5
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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">A–Z</option>
|
||||
<option value="title_desc">Z–A</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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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">A–Z</option>
|
||||
<option value="title_desc">Z–A</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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user