Compare commits
80 Commits
v1.6.0
...
release/1.7.14
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c | |||
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 | |||
| d4ee3b8ef7 | |||
| 64c872423f | |||
| 86c67bcf29 | |||
| 9548eb41f5 | |||
| d1db3118f0 | |||
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e | |||
| a021ceba47 | |||
| f8c7e35f31 | |||
| de71580756 | |||
| 2943afdbaf | |||
| 1d571b066d | |||
| db809f2fb3 | |||
| 9d91d85514 | |||
| f52a687a46 | |||
| e3f90d54f4 | |||
| a006cb4a37 | |||
| 4ddd3036d9 | |||
| 4cd9faaf25 | |||
| 2e9fe8e049 | |||
| 12c44a611e | |||
| 614af9eb44 | |||
| b77c0d6ec0 | |||
| 44c553709c | |||
| 8376aa0c0b | |||
| e8a149427a | |||
| 548aca6bee | |||
| 4aa3590017 | |||
| d3d085d614 | |||
| dbf45ec31d | |||
| f1e0a77fad | |||
| 9862c0555c | |||
| 26d9e429a9 | |||
| 1dccda529a | |||
| a85747a4c5 | |||
| 884fb5285f | |||
| e8037afbb8 | |||
| 4d860dc787 | |||
| ecaedbaf6a | |||
| 9621aec453 | |||
| ed4237debb | |||
| de9a9284dc | |||
| 52a75fd8cb | |||
| 4941b69924 | |||
| 37bed1cd4e | |||
| 1a4ff73067 | |||
| afa6ebc3c7 | |||
| 1ed01d0ef0 | |||
| f3e1bd17fb | |||
| bcdbbec804 | |||
| db9b3e7a30 | |||
| e254873bee | |||
| 7dadb849f6 | |||
| 6980558ca9 | |||
| a141bb57d6 | |||
| 43f5a52749 | |||
| 5c0ad7cb1b | |||
| a21bafa041 | |||
| 12effe17d3 | |||
| 1bb9e4014e | |||
| 964dacc588 | |||
| 777fa26e5b | |||
| 93a8c3fd2e |
@@ -157,6 +157,12 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# RADARR_URL=https://radarr.example.com
|
||||
# RADARR_API_KEY=your-radarr-api-key
|
||||
|
||||
# =============================================================================
|
||||
# OMBI (Request Management - Optional)
|
||||
# =============================================================================
|
||||
OMBI_URL=https://ombi.example.com
|
||||
OMBI_API_KEY=your-ombi-api-key-here
|
||||
|
||||
# =============================================================================
|
||||
# NOTES
|
||||
# =============================================================================
|
||||
|
||||
@@ -23,17 +23,34 @@ jobs:
|
||||
if [[ "$BRANCH" == develop* ]]; then
|
||||
# Sanitise branch name for tag: replace slashes with dashes
|
||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
||||
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image tags: ${TAGS}"
|
||||
else
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
|
||||
# Primary registry tags
|
||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||
|
||||
# Gitea package registry tags
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
||||
echo "Building release image tags: ${TAGS}"
|
||||
fi
|
||||
|
||||
- name: Log into Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.i3omb.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
@@ -60,3 +60,54 @@ jobs:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
|
||||
swagger:
|
||||
name: Swagger Validation & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint OpenAPI spec with Spectral
|
||||
run: npx @stoplight/spectral-cli lint server/openapi.yaml --ruleset .spectral.yml || true
|
||||
|
||||
- name: Run Swagger coverage tests
|
||||
run: npm test -- tests/integration/swagger-coverage.test.js
|
||||
env:
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Generate merged OpenAPI spec
|
||||
run: npm run generate:openapi
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATA_DIR: /tmp/sofarr-ci-data
|
||||
SKIP_RATE_LIMIT: "1"
|
||||
|
||||
- name: Convert to RAML
|
||||
run: npm run generate:raml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Package RAML artifact
|
||||
run: npm run package:raml
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
- name: Upload RAML package artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: raml-package
|
||||
path: dist/raml-*.tar.gz
|
||||
retention-days: 14
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
# Check for incompatible licenses
|
||||
if ! npx --yes license-checker --production \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0;Python-2.0" \
|
||||
--excludePrivatePackages; then
|
||||
echo ""
|
||||
echo "❌ Found incompatible licenses. Full license report:"
|
||||
|
||||
@@ -10,3 +10,5 @@ data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.agents/
|
||||
.windsurf/
|
||||
@@ -0,0 +1,10 @@
|
||||
extends: spectral:oas
|
||||
rules:
|
||||
# Ensure all operations have descriptions
|
||||
operation-description: warn
|
||||
# Ensure all paths have parameters defined
|
||||
path-params-defined: error
|
||||
# Ensure all schemas have examples where appropriate
|
||||
example-provided: warn
|
||||
# Disable rules that are too strict for this project
|
||||
operation-operationId: off
|
||||
+239
-10
@@ -51,7 +51,9 @@ 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
|
||||
|
||||
subgraph Server["Express Server (:3001)"]
|
||||
@@ -60,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"]
|
||||
|
||||
@@ -81,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
|
||||
@@ -96,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
|
||||
```
|
||||
@@ -113,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)
|
||||
@@ -122,12 +128,16 @@ Express Server (:3001)
|
||||
├── cookie-parser (HMAC-signed session cookie)
|
||||
├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook)
|
||||
│
|
||||
├── /api/swagger → Swagger UI (public, auth banner for testing)
|
||||
├── /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
|
||||
|
||||
@@ -263,10 +273,15 @@ The rest of the application (poller, dashboard) receives data in the same format
|
||||
|
||||
#### Overview
|
||||
|
||||
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever` or `PollingRadarrRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
|
||||
`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever`, `PollingRadarrRetriever`, or `OmbiRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type.
|
||||
|
||||
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
|
||||
|
||||
**Supported Retrievers:**
|
||||
- **PollingSonarrRetriever**: TV series data from Sonarr instances
|
||||
- **PollingRadarrRetriever**: Movie data from Radarr instances
|
||||
- **OmbiRetriever**: Request management data from Ombi instances
|
||||
|
||||
#### Error handling
|
||||
|
||||
Retriever methods now throw on HTTP failure rather than swallowing errors silently. This ensures the poller logs upstream problems and skips the affected instance cleanly instead of caching stale empty data.
|
||||
@@ -278,15 +293,32 @@ arrRetrieverRegistry = {
|
||||
async initialize() // idempotent; reads config once
|
||||
getAllRetrievers(): ArrRetriever[]
|
||||
getRetriever(instanceId): ArrRetriever | null
|
||||
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr'
|
||||
getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr' | 'ombi'
|
||||
|
||||
// Typed fetch methods — all return { sonarr: [...], radarr: [...] }
|
||||
async getQueuesByType(): Promise<{ sonarr, radarr }>
|
||||
async getHistoryByType(options?): Promise<{ sonarr, radarr }>
|
||||
async getTagsByType(): Promise<{ sonarr, radarr }>
|
||||
|
||||
// Ombi-specific methods
|
||||
getOmbiRetrievers(): OmbiRetriever[]
|
||||
async getOmbiRequests(): Promise<{ movie: [], tv: [] }>
|
||||
async getOmbiRequestsByType(): Promise<{ movie: [], tv: [] }>
|
||||
async findOmbiRequest(type, externalIds): Promise<Object | null>
|
||||
}
|
||||
```
|
||||
|
||||
#### 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
|
||||
@@ -350,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:
|
||||
@@ -438,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
|
||||
@@ -526,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -618,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
|
||||
@@ -654,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 |
|
||||
@@ -826,6 +928,108 @@ Related functions in `filters.js`:
|
||||
|
||||
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
|
||||
|
||||
### 7.4 API Documentation System
|
||||
|
||||
sofarr exposes interactive API documentation via **Swagger UI** at `/api/swagger`, using a hybrid documentation model that balances maintainability with consistency.
|
||||
|
||||
#### Architecture
|
||||
|
||||
```
|
||||
server/openapi.yaml Central spec: base metadata, security schemes, reusable schemas
|
||||
│
|
||||
│ merge at runtime (swagger-jsdoc)
|
||||
▼
|
||||
server/routes/*.js JSDoc @openapi comments per endpoint
|
||||
│
|
||||
│ serve (swagger-ui-express)
|
||||
▼
|
||||
GET /api/swagger Swagger UI HTML (public, auth banner)
|
||||
GET /api/swagger.json Merged OpenAPI 3.1 spec JSON
|
||||
```
|
||||
|
||||
#### Central OpenAPI Specification (`server/openapi.yaml`)
|
||||
|
||||
The YAML file defines:
|
||||
- **Base metadata** — `openapi: 3.1.0`, info, server URL, contact
|
||||
- **Security schemes** — `CookieAuth` (`emby_user` cookie), `CsrfToken` (`X-CSRF-Token` header)
|
||||
- **Component schemas** — `NormalizedDownload`, `DashboardPayload`, `ErrorResponse`, `BlocklistSearchRequest`, `WebhookPayload`, `HistoryItem`, `StatusResponse`
|
||||
- **Path placeholders** — stub entries for every endpoint so JSDoc comments have a merge target
|
||||
|
||||
#### JSDoc `@openapi` Comments
|
||||
|
||||
Every route handler file contains JSDoc comments above each Express route definition:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @openapi
|
||||
* /api/auth/login:
|
||||
* post:
|
||||
* tags: [Authentication]
|
||||
* summary: Authenticate with Emby
|
||||
* ...
|
||||
*/
|
||||
router.post('/login', ...)
|
||||
```
|
||||
|
||||
`swagger-jsdoc` scans `server/routes/**/*.js` and merges the YAML from these comments into the central spec at runtime.
|
||||
|
||||
#### Machine-Usable Extensions
|
||||
|
||||
For AI agents and automated tooling, every endpoint includes:
|
||||
- **`x-code-samples`** — cURL, JavaScript fetch, and TypeScript examples
|
||||
- **`x-integration-notes`** — Human-readable integration guidance embedded in descriptions
|
||||
|
||||
#### Coverage Validation
|
||||
|
||||
`tests/integration/swagger-coverage.test.js` (22 tests) validates:
|
||||
- The YAML spec parses without errors
|
||||
- Every Express route appears in the merged spec
|
||||
- All examples are valid JSON
|
||||
- Security schemes are correctly referenced (`CookieAuth`, `CsrfToken`)
|
||||
- The Swagger UI endpoint returns `200`
|
||||
|
||||
#### CI/CD Integration
|
||||
|
||||
`.gitea/workflows/ci.yml` includes a "Swagger Validation & Coverage" job that:
|
||||
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||
- Runs the coverage test suite on every push
|
||||
|
||||
### 7.5 Real-Time Debug Log Streaming Subsystem
|
||||
|
||||
sofarr provides a togglable, real-time log capturing and streaming engine allowing developer-administrators to view server standard output stream activity and browser console log activity for easy debugging in production.
|
||||
|
||||
#### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser (SPA)
|
||||
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
||||
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
||||
end
|
||||
|
||||
subgraph Node.js (Server)
|
||||
stdout["process.stdout.write"] --> capture["processStreamData()"]
|
||||
stderr["process.stderr.write"] --> capture
|
||||
capture --> |stripAnsi()| serverBuffer["logBuffer (rolling 1000 lines)"]
|
||||
capture --> |emit('server-log')| serverSse["GET /api/debug/server-logs (SSE)"]
|
||||
|
||||
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
|
||||
ingestionRoute --> |emit('client-log')| clientSse["GET /api/debug/client-logs (SSE)"]
|
||||
end
|
||||
```
|
||||
|
||||
#### In-Process Interceptor (Stdout & Stderr)
|
||||
To guarantee 100% logging completeness without requiring complex and insecure external Docker daemon sockets, the server hooks directly into standard output stream writers `process.stdout.write` and `process.stderr.write` during the process boot cycle.
|
||||
- Custom accumulators process streams, strip ANSI terminal colors (colors are stripped using a standard regex matching all terminal escape codes), and split incoming chunks into structured lines.
|
||||
- Historical lines are appended to a rolling in-memory array `logBuffer` capped at 1,000 entries.
|
||||
- Real-time logging events are broadcasted via a central `EventEmitter` allowing connected SSE stream clients to receive updates instantly.
|
||||
|
||||
#### Client Console Log Capture
|
||||
To assist developers in troubleshooting client-side runtime errors without access to developer consoles (e.g., in embedded WebViews, smart TVs, or custom mobile browser instances), the SPA implements an automatic logging interceptor.
|
||||
- If `/api/debug/status` indicates log streaming is active, the bootstrap process replaces global `console.log`, `console.warn`, and `console.error` methods.
|
||||
- Logs are added to an in-memory queue and flushed in batches using a stateless `POST /api/debug/client-logs` request every 2,000ms (or when the queue size reaches 20 items) to prevent browser thread blocking.
|
||||
- A synchronous cleanup check is registered via standard `beforeunload` to flush any remaining logs using `navigator.sendBeacon()` or `keepalive: true` fetch on page refresh or navigate.
|
||||
|
||||
---
|
||||
|
||||
## 8. Directory Structure
|
||||
@@ -842,7 +1046,10 @@ sofarr/
|
||||
│ │ ├── TransmissionClient.js
|
||||
│ │ ├── RTorrentClient.js
|
||||
│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever
|
||||
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
||||
│ │ ├── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
||||
│ │ ├── ArrRetriever.js PALDRA — Abstract base class for *arr retrievers
|
||||
│ │ ├── OmbiClient.js Low-level Ombi API client
|
||||
│ │ └── OmbiRetriever.js PALDRA — Ombi retriever with caching
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
|
||||
│ │ ├── dashboard.js SSE /stream, /user-downloads, /blocklist-search
|
||||
@@ -1017,7 +1224,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
|
||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
|
||||
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
||||
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
||||
|
||||
@@ -1025,13 +1232,25 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---------|-----------|
|
||||
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
|
||||
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
|
||||
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
||||
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
||||
| **Body size** | `express.json` body limit: 64 KB. |
|
||||
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
|
||||
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
|
||||
|
||||
### 10.4 Debug Log Streaming Security
|
||||
|
||||
Access to the debug log stream endpoints (`/api/debug/server-logs` and `/api/debug/client-logs`) is secured via a strict multi-layered policy:
|
||||
|
||||
| Layer | Mechanism |
|
||||
|-------|-----------|
|
||||
| **Feature lock toggle** | Strictly disabled by default; only enableable when environment variable `ENABLE_LOG_STREAM=true` is set. |
|
||||
| **Subnet filtering** | If environment variable `LOG_ALLOW_SUBNETS` is configured (using standard comma-separated CIDR notation), incoming client IPs (`req.ip`) are parsed and validated via the `ipaddr.js` library. Unmatched subnets receive a `403 Forbidden` response immediately. |
|
||||
| **Fast-path webhook bypass** | A valid webhook secret bypasses the active Emby authentication layer when provided on the `X-Webhook-Secret` header. |
|
||||
| **Active Emby session** | Validates that an active `emby_user` session cookie is present and that the authenticated Emby user is an administrator. |
|
||||
| **Emby basic auth fallback** | Allows Basic Authentication headers by performing an on-demand asynchronous credentials check against Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to assert active policy administrator status. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Technology Stack
|
||||
@@ -1047,6 +1266,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
| Frontend | Vanilla JS + CSS | SPA; Vite bundles ES modules from `client/src/` into `public/app.js` |
|
||||
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
|
||||
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
|
||||
| Request Management | Ombi (optional) | External ID matching and request linking |
|
||||
|
||||
### Security Middleware
|
||||
|
||||
@@ -1056,6 +1276,15 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
| `express-rate-limit` | 7.x | General, login, and webhook rate limiters |
|
||||
| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||
|
||||
### API Documentation
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `swagger-ui-express` | 5.x | Serve interactive Swagger UI at `/api/swagger` |
|
||||
| `swagger-jsdoc` | 6.x | Merge JSDoc `@openapi` comments with central YAML spec |
|
||||
| `yamljs` | 0.3.x | Parse `server/openapi.yaml` at runtime for swagger-jsdoc |
|
||||
| `@stoplight/spectral-cli` | 6.x (dev) | Lint OpenAPI spec for correctness in CI |
|
||||
|
||||
### Auth and Session
|
||||
|
||||
| Component | Technology | Details |
|
||||
|
||||
+229
@@ -4,6 +4,235 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.14] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.12] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
|
||||
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
|
||||
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
|
||||
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
|
||||
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
|
||||
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.11] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.10] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.9] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.8] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.7] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.6] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.5] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.4] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook registration in production** — Fixed a bug where `/api/ombi/webhook/enable` and other `/api/ombi/*` endpoints returned `404 (Not Found)` in production. The core Express app factory (`server/app.js`) registered the router, but the production server entry point (`server/index.js`) duplicated Express setup instead of utilizing the factory, omitting the `ombiRoutes` registration entirely. Re-registered the router in `server/index.js`, resolves Gitea Issue [#40](https://git.i3omb.com/Gandalf/sofarr/issues/40).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
#### Ombi PALDRA Integration
|
||||
|
||||
- **OmbiRetriever** — New PALDRA-compliant retriever extending `ArrRetriever`, registered in `arrRetrieverRegistry` alongside Sonarr/Radarr. Manages Ombi request data with 5-minute TTL cache and lookup maps by TMDB/TVDB/IMDB IDs.
|
||||
- **OmbiClient** — Low-level Ombi API client for HTTP communication (movie/TV requests, search by external ID, connection test).
|
||||
- **`getOmbiInstances()`** — New config function in `server/utils/config.js` following the existing multi-instance JSON array pattern; supports both `OMBI_INSTANCES` and legacy `OMBI_URL`/`OMBI_API_KEY` formats.
|
||||
- **PALDRA registry Ombi methods** — `getOmbiRetrievers()`, `getOmbiRequests()`, `getOmbiRequestsByType()`, `findOmbiRequest()` added to `arrRetrieverRegistry`.
|
||||
- **External ID matching** — Downloads are matched to Ombi requests using TVDB ID → TMDB ID (TV) and TMDB ID → IMDB ID (movies); falls back to an Ombi search link when no request exists.
|
||||
- **`getOmbiLink()` / `getOmbiSearchLink()`** — New helpers in `DownloadAssembler.js` following the `getSonarrLink`/`getRadarrLink` pattern.
|
||||
- **Service icon layout** — Downloads and history cards now render inline SVG icons (Ombi for all users; Sonarr/Radarr for admins) instead of linked series/movie names. CSS `.service-icons-container` and `.service-icon` classes added to `public/style.css`.
|
||||
- **OpenAPI** — `NormalizedDownload` schema extended with `ombiLink`, `ombiRequestId`, `ombiTooltip` nullable string properties; `Ombi` tag added to the spec.
|
||||
- **`OMBI_INSTANCES` / `OMBI_URL` / `OMBI_API_KEY`** — New environment variables documented in `.env.sample`, `README.md`, `ARCHITECTURE.md`, and `SECURITY.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`DownloadMatcher.js`** — `matchSabSlots`, `matchSabHistory`, and `matchTorrents` are now `async`; each matched download object is enriched with `ombiLink`, `ombiRequestId`, and `ombiTooltip` via `addOmbiMatching()`.
|
||||
- **`DownloadBuilder.js`** — `buildUserDownloads` accepts `ombiRetriever` and `ombiBaseUrl` in its options object and passes them through to matching context.
|
||||
- **Dashboard routes** — Both the REST endpoint and SSE stream now resolve the Ombi retriever from the PALDRA registry and include it in the download-building context.
|
||||
- **`arrRetrievers.js`** — PALDRA registry now imports `OmbiRetriever`, maps `'ombi'` in `retrieverClasses`, and initialises instances from `getOmbiInstances()`.
|
||||
- **`ARCHITECTURE.md`** — PALDRA section updated with OmbiRetriever description, registry API additions, and directory-structure entries. Technology stack table updated.
|
||||
- **`SECURITY.md`** — Threat model extended with Ombi API key exposure and rate-limit exhaustion mitigations.
|
||||
- **`README.md`** — Prerequisites and new *Ombi Integration (Optional)* configuration section added.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.1] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
#### RAML 1.0 Package Generation
|
||||
|
||||
- **Automated RAML generation in CI/CD** — Added RAML 1.0 package generation to the existing `swagger` job in `.gitea/workflows/ci.yml`. The pipeline now generates a downloadable `raml-package` artifact on every push and PR, available from the Actions run page with 14-day retention.
|
||||
- **RAML generation scripts** — Created three new scripts in `scripts/`:
|
||||
- `generate-openapi.js` — Bootstraps the Express app in test mode, fetches the merged OpenAPI 3.1 spec from `/api/swagger.json`, and exports it to disk.
|
||||
- `downgrade-openapi.js` — Downgrades OpenAPI 3.1 to 3.0 for RAML compatibility (existing RAML converters don't support 3.1).
|
||||
- `simple-raml-converter.js` — Converts OpenAPI 3.0 to RAML 1.0 format using a custom converter (modern RAML converters are unmaintained).
|
||||
- `package-raml.js` — Creates a versioned tar.gz archive containing the RAML spec, original OpenAPI spec, version metadata, and README.
|
||||
- **RAML artifact structure** — Each artifact includes:
|
||||
- `api.raml` — RAML 1.0 specification
|
||||
- `openapi-merged.json` — Original merged OpenAPI 3.1.0 spec (for reference)
|
||||
- `version.json` — Metadata (version, commit SHA, timestamp, tool used)
|
||||
- `README.md` — Origin, conversion details, known limitations, and verification steps
|
||||
- **npm scripts** — Added three new scripts to `package.json`:
|
||||
- `generate:openapi` — Generates merged OpenAPI spec
|
||||
- `generate:raml` — Downgrades and converts to RAML
|
||||
- `package:raml` — Packages the RAML artifact
|
||||
- **Spectral ruleset** — Created minimal `.spectral.yml` ruleset to make the existing Spectral lint step functional (previously failing silently due to missing ruleset).
|
||||
- **OpenAPI JSON endpoint** — Added `/api/swagger.json` endpoint to `server/app.js` to serve the raw merged OpenAPI spec as JSON, enabling programmatic access.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Dependencies added** — `archiver` (^7.0.1) for creating tar.gz archives.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Swagger UI & OpenAPI 3.1 Documentation
|
||||
|
||||
- **Swagger UI at `/api/swagger`** — Interactive API documentation served via `swagger-ui-express`; publicly accessible with a custom authentication banner (`public/swagger-auth-banner.js`) that explains the cookie-based + CSRF-token authentication flow for testing endpoints directly in the browser.
|
||||
- **OpenAPI 3.1 specification** — Central `server/openapi.yaml` file containing base metadata, security schemes (`CookieAuth`, `CsrfToken`), and reusable component schemas:
|
||||
- `NormalizedDownload` — standardised download object returned by all PDCA clients
|
||||
- `DashboardPayload` — SSE payload shape (`{ user, isAdmin, downloads, downloadClients }`)
|
||||
- `ErrorResponse` — standard error envelope with redacted details
|
||||
- `BlocklistSearchRequest` — payload for the admin blocklist-and-search operation
|
||||
- `WebhookPayload` — Sonarr/Radarr webhook event structure
|
||||
- `HistoryItem` — deduplicated history record with upgrade-availability flag
|
||||
- `StatusResponse` — server metrics, polling timings, cache stats, and webhook metrics
|
||||
- **Hybrid documentation approach** — Per-endpoint details are documented directly in route handler files (`server/routes/*.js`) using JSDoc `@openapi` comments. `swagger-jsdoc` merges these comments with the central YAML at runtime, keeping documentation close to the code while maintaining shared schemas in one place.
|
||||
- **Comprehensive endpoint coverage** — All implemented endpoints are documented:
|
||||
- Authentication: `POST /api/auth/login`, `GET /api/auth/me`, `GET /api/auth/csrf`, `POST /api/auth/logout`
|
||||
- Dashboard: `GET /api/dashboard/stream` (SSE), `GET /api/dashboard/user-downloads` (deprecated), `GET /api/dashboard/cover-art`, `POST /api/dashboard/blocklist-search`
|
||||
- Status: `GET /api/status`
|
||||
- History: `GET /api/history/recent`
|
||||
- Webhooks: `POST /api/webhook/sonarr`, `POST /api/webhook/radarr`
|
||||
- Proxy routes: Sonarr, Radarr, SABnzbd, and Emby authenticated proxies
|
||||
- Public health: `GET /health`, `GET /ready`
|
||||
- **Machine-usable extensions** — Every documented endpoint includes:
|
||||
- `x-code-samples` with cURL, JavaScript fetch, and TypeScript examples
|
||||
- `x-integration-notes` section in descriptions for AI agents and automated tooling
|
||||
- Realistic request/response examples and full JSON Schema definitions
|
||||
- **Coverage validation test suite** — `tests/integration/swagger-coverage.test.js` (22 tests) validates that:
|
||||
- The OpenAPI spec loads without YAML parse errors
|
||||
- Every Express route appears in the merged spec
|
||||
- All schema and response examples are valid JSON
|
||||
- Required security schemes (`CookieAuth`, `CsrfToken`) are defined and referenced correctly
|
||||
- The Swagger UI HTML endpoint (`GET /api/swagger`) returns `200`
|
||||
- **CI/CD validation job** — Added "Swagger Validation & Coverage" job in `.gitea/workflows/ci.yml` that runs on every push:
|
||||
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||
- Runs `npm test -- tests/integration/swagger-coverage.test.js` to verify coverage
|
||||
|
||||
### Changed
|
||||
|
||||
- **Dependencies added** — `swagger-ui-express` (^5.0.1), `swagger-jsdoc` (^6.2.8), `yamljs` (^0.3.0), and `@stoplight/spectral-cli` (^6.16.0 dev dependency) for OpenAPI generation, UI serving, and spec linting.
|
||||
|
||||
### Security
|
||||
|
||||
- **Swagger UI public access** — The Swagger UI endpoint (`/api/swagger`) is publicly accessible by design for convenience. All documented API endpoints still enforce authentication (`emby_user` cookie) and CSRF protection (`X-CSRF-Token` header for mutations) as before. The authentication banner in the UI explicitly instructs users to log in via `POST /api/auth/login` first before testing protected endpoints.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
|
||||
|
||||
Version 1.5.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
||||
Version 1.7.x adds **interactive Swagger UI and OpenAPI 3.1 documentation** at `/api/swagger` — explore, test, and integrate with the full API using a hybrid YAML + JSDoc documentation system.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -93,6 +93,7 @@ SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
- Sonarr (optional, for TV tracking)
|
||||
- Radarr (optional, for movie tracking)
|
||||
- Emby (for user authentication)
|
||||
- Ombi (optional, for request management integration)
|
||||
|
||||
## Docker Deployment (Recommended)
|
||||
|
||||
@@ -226,6 +227,10 @@ PORT=3001 # Server port
|
||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
|
||||
# Debug Log Streaming Subsystem
|
||||
ENABLE_LOG_STREAM=false # Set to true to enable logs debugging routes
|
||||
LOG_ALLOW_SUBNETS=127.0.0.1/32 # Comma-separated allowed CIDR blocks (e.g. 127.0.0.1/32,192.168.1.0/24)
|
||||
```
|
||||
|
||||
### Webhooks & Smart Polling
|
||||
@@ -305,6 +310,31 @@ RTORRENT_USERNAME=rtorrent
|
||||
RTORRENT_PASSWORD=rtorrent
|
||||
```
|
||||
|
||||
### Ombi Integration (Optional)
|
||||
|
||||
sofarr integrates with Ombi for request management, allowing downloads to be linked to their originating Ombi requests. This provides direct access to request details and enables seamless navigation between downloads and requests.
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
# JSON array format (recommended for multiple instances)
|
||||
OMBI_INSTANCES=[{"name":"main","url":"https://ombi.example.com","apiKey":"your-ombi-api-key"}]
|
||||
|
||||
# Legacy single-instance format
|
||||
OMBI_URL=https://ombi.example.com
|
||||
OMBI_API_KEY=your-ombi-api-key
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||
@@ -353,6 +383,49 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
- **Peers** - Number of peers
|
||||
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
||||
|
||||
## API Documentation (Swagger UI)
|
||||
|
||||
sofarr provides interactive API documentation via Swagger UI, available at:
|
||||
|
||||
**`http://your-server:3001/api/swagger`**
|
||||
|
||||
### Authentication in Swagger UI
|
||||
|
||||
sofarr uses cookie-based authentication with Emby/Jellyfin. To test authenticated endpoints in Swagger UI:
|
||||
|
||||
1. **Login via Swagger UI:**
|
||||
- Expand `POST /api/auth/login`
|
||||
- Click "Try it out"
|
||||
- Enter your Emby username and password
|
||||
- Click "Execute"
|
||||
- The browser will automatically save the session cookies
|
||||
|
||||
2. **For state-changing requests (POST/PUT/PATCH/DELETE):**
|
||||
- Swagger UI automatically includes the `X-CSRF-Token` header from your cookies
|
||||
- No manual header configuration needed
|
||||
|
||||
3. **For GET requests:**
|
||||
- Cookies are sent automatically
|
||||
- No additional configuration needed
|
||||
|
||||
### Hybrid Documentation Approach
|
||||
|
||||
sofarr uses a hybrid documentation model to maintain clean, maintainable API documentation:
|
||||
|
||||
- **Central OpenAPI Specification (`server/openapi.yaml`)**: Contains base metadata, security schemes, component schemas (NormalizedDownload, DashboardPayload, ErrorResponse, etc.), and path definitions. This is the single source of truth for shared data structures and global configuration.
|
||||
|
||||
- **JSDoc `@openapi` Comments in Route Files**: Per-endpoint details are documented directly in the route handler files (`server/routes/*.js`) using JSDoc `@openapi` comments. swagger-jsdoc merges these comments with the central YAML at runtime, keeping documentation close to the code while maintaining a clean separation of concerns.
|
||||
|
||||
This approach provides:
|
||||
- **Maintainability**: Endpoint details live alongside the code they document
|
||||
- **Consistency**: Shared schemas are defined once in the central YAML
|
||||
- **Flexibility**: Easy to update documentation when code changes
|
||||
- **Machine-Usability**: Full JSON Schema with realistic examples, code samples, and integration notes for AI agents and automated tools
|
||||
|
||||
### Proxy Routes
|
||||
|
||||
The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/emby/**) are authenticated proxies to upstream services. These endpoints reflect the APIs of Sonarr, Radarr, SABnzbd, and Emby respectively, and are documented to show the specific endpoints implemented in sofarr (not the full upstream API surface).
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
@@ -372,11 +445,19 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
### History
|
||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||
|
||||
### Debug Logs (requires ENABLE_LOG_STREAM=true)
|
||||
- `GET /api/debug/status` — Get runtime log stream configurations (public)
|
||||
- `GET /api/debug/server-logs` — **SSE stream**: push server `stdout`/`stderr` in real-time (requires auth & subnet check)
|
||||
- `GET /api/debug/client-logs` — **SSE stream**: push ingested frontend console logs in real-time (requires auth & subnet check)
|
||||
- `POST /api/debug/client-logs` — Ingest batched frontend console logs (requires auth & subnet check)
|
||||
|
||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||
- `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
|
||||
@@ -385,6 +466,12 @@ sofarr polls all configured services in the background and caches the results. D
|
||||
- `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
|
||||
@@ -429,7 +516,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
|
||||
|
||||
|
||||
+16
-8
@@ -4,9 +4,12 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.4.x | ✅ Yes |
|
||||
| 1.3.x | ✅ Yes |
|
||||
| 1.2.x | ✅ Yes |
|
||||
| 1.7.x | ✅ Yes |
|
||||
| 1.6.x | ✅ Yes |
|
||||
| 1.5.x | ✅ Yes |
|
||||
| 1.4.x | ❌ No |
|
||||
| 1.3.x | ❌ No |
|
||||
| 1.2.x | ❌ No |
|
||||
| 1.1.x | ❌ No |
|
||||
| 1.0.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
@@ -41,6 +44,9 @@ users via Emby. The primary threat surface when exposed to the public internet:
|
||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
||||
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
|
||||
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||
| API documentation disclosure | Swagger UI at `/api/swagger` publicly exposes endpoint structure; mitigated by endpoint auth requirements and CSRF protection on all mutations |
|
||||
| Ombi API key exposure | API keys stored in environment variables, never logged; `sanitizeError()` redacts Ombi credentials; Ombi retriever uses 5-minute cache to minimize API calls |
|
||||
| Ombi rate limit exhaustion | Ombi retriever includes 5-minute TTL cache to reduce API call frequency; graceful degradation if Ombi is unavailable |
|
||||
|
||||
---
|
||||
|
||||
@@ -156,11 +162,13 @@ server {
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
| Endpoint | Limit | Details & Exemptions |
|
||||
|----------|-------|----------------------|
|
||||
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+65
-4
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
@@ -137,7 +139,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 +160,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 +173,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 +182,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 +195,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 +221,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 +310,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';
|
||||
@@ -10,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
|
||||
import { initTabs, goHome } from './ui/tabs.js';
|
||||
import { handleShowAllToggle } from './sse.js';
|
||||
import { loadAppVersion } from './api.js';
|
||||
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize client console log capturing early
|
||||
initClientLogCapture();
|
||||
|
||||
// Login form
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
@@ -46,6 +51,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
|
||||
|
||||
@@ -50,6 +50,48 @@ function createClientLogo(download) {
|
||||
return clientLogoWrapper;
|
||||
}
|
||||
|
||||
function createServiceIcons(download) {
|
||||
const container = document.createElement('span');
|
||||
container.className = 'service-icons-container';
|
||||
|
||||
// Add Ombi icon for all users if ombiLink exists
|
||||
if (download.ombiLink) {
|
||||
const ombiIcon = document.createElement('img');
|
||||
ombiIcon.className = 'service-icon ombi';
|
||||
ombiIcon.src = '/images/ombi.svg';
|
||||
ombiIcon.alt = 'Ombi';
|
||||
ombiIcon.title = download.ombiTooltip || 'Ombi';
|
||||
ombiIcon.href = download.ombiLink;
|
||||
const ombiLink = document.createElement('a');
|
||||
ombiLink.href = download.ombiLink;
|
||||
ombiLink.target = '_blank';
|
||||
ombiLink.appendChild(ombiIcon);
|
||||
container.appendChild(ombiLink);
|
||||
}
|
||||
|
||||
// Add Sonarr/Radarr icon for admin users if arrLink exists
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
const arrIcon = document.createElement('img');
|
||||
if (download.arrType === 'sonarr') {
|
||||
arrIcon.className = 'service-icon sonarr';
|
||||
arrIcon.src = '/images/sonarr.svg';
|
||||
arrIcon.alt = 'Sonarr';
|
||||
} else if (download.arrType === 'radarr') {
|
||||
arrIcon.className = 'service-icon radarr';
|
||||
arrIcon.src = '/images/radarr.svg';
|
||||
arrIcon.alt = 'Radarr';
|
||||
}
|
||||
arrIcon.title = download.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||
const arrLink = document.createElement('a');
|
||||
arrLink.href = download.arrLink;
|
||||
arrLink.target = '_blank';
|
||||
arrLink.appendChild(arrIcon);
|
||||
container.appendChild(arrLink);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
@@ -230,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType,
|
||||
isAdmin: state.isAdmin,
|
||||
canBlocklist: download.canBlocklist
|
||||
@@ -346,11 +390,19 @@ export function createDownloadCard(download) {
|
||||
if (download.seriesName) {
|
||||
const series = document.createElement('p');
|
||||
series.className = 'download-series';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
||||
} else {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
|
||||
// Add service icons
|
||||
const serviceIcons = createServiceIcons(download);
|
||||
if (serviceIcons.hasChildNodes()) {
|
||||
series.appendChild(serviceIcons);
|
||||
series.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
|
||||
// Series name is now plain text for all users (no link)
|
||||
const seriesText = document.createElement('span');
|
||||
seriesText.textContent = `Series: ${download.seriesName}`;
|
||||
series.appendChild(seriesText);
|
||||
|
||||
infoDiv.appendChild(series);
|
||||
const epEl = formatEpisodeInfo(download.episodes);
|
||||
if (epEl) infoDiv.appendChild(epEl);
|
||||
@@ -359,11 +411,19 @@ export function createDownloadCard(download) {
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
||||
} else {
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
|
||||
// Add service icons
|
||||
const serviceIcons = createServiceIcons(download);
|
||||
if (serviceIcons.hasChildNodes()) {
|
||||
movie.appendChild(serviceIcons);
|
||||
movie.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
|
||||
// Movie name is now plain text for all users (no link)
|
||||
const movieText = document.createElement('span');
|
||||
movieText.textContent = `Movie: ${download.movieName}`;
|
||||
movie.appendChild(movieText);
|
||||
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
|
||||
+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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,47 @@ import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
|
||||
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
import { renderTagBadges } from './downloads.js';
|
||||
|
||||
function createServiceIcons(item) {
|
||||
const container = document.createElement('span');
|
||||
container.className = 'service-icons-container';
|
||||
|
||||
// Add Ombi icon for all users if ombiLink exists
|
||||
if (item.ombiLink) {
|
||||
const ombiIcon = document.createElement('img');
|
||||
ombiIcon.className = 'service-icon ombi';
|
||||
ombiIcon.src = '/images/ombi.svg';
|
||||
ombiIcon.alt = 'Ombi';
|
||||
ombiIcon.title = item.ombiTooltip || 'Ombi';
|
||||
const ombiLink = document.createElement('a');
|
||||
ombiLink.href = item.ombiLink;
|
||||
ombiLink.target = '_blank';
|
||||
ombiLink.appendChild(ombiIcon);
|
||||
container.appendChild(ombiLink);
|
||||
}
|
||||
|
||||
// Add Sonarr/Radarr icon for admin users if arrLink exists
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
const arrIcon = document.createElement('img');
|
||||
if (item.arrType === 'sonarr') {
|
||||
arrIcon.className = 'service-icon sonarr';
|
||||
arrIcon.src = '/images/sonarr.svg';
|
||||
arrIcon.alt = 'Sonarr';
|
||||
} else if (item.arrType === 'radarr') {
|
||||
arrIcon.className = 'service-icon radarr';
|
||||
arrIcon.src = '/images/radarr.svg';
|
||||
arrIcon.alt = 'Radarr';
|
||||
}
|
||||
arrIcon.title = item.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||
const arrLink = document.createElement('a');
|
||||
arrLink.href = item.arrLink;
|
||||
arrLink.target = '_blank';
|
||||
arrLink.appendChild(arrIcon);
|
||||
container.appendChild(arrLink);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function initHistoryControls() {
|
||||
const daysInput = document.getElementById('history-days');
|
||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||
@@ -158,15 +199,23 @@ export function createHistoryCard(item) {
|
||||
title.textContent = item.title;
|
||||
info.appendChild(title);
|
||||
|
||||
// Series/movie name with optional arr link
|
||||
// Series/movie name with service icons
|
||||
if (item.seriesName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
|
||||
// Add service icons
|
||||
const serviceIcons = createServiceIcons(item);
|
||||
if (serviceIcons.hasChildNodes()) {
|
||||
p.appendChild(serviceIcons);
|
||||
p.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
|
||||
// Series name is now plain text for all users (no link)
|
||||
const seriesText = document.createElement('span');
|
||||
seriesText.textContent = 'Series: ' + item.seriesName;
|
||||
p.appendChild(seriesText);
|
||||
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
@@ -174,11 +223,19 @@ export function createHistoryCard(item) {
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Movie: ' + item.movieName;
|
||||
|
||||
// Add service icons
|
||||
const serviceIcons = createServiceIcons(item);
|
||||
if (serviceIcons.hasChildNodes()) {
|
||||
p.appendChild(serviceIcons);
|
||||
p.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
|
||||
// Movie name is now plain text for all users (no link)
|
||||
const movieText = document.createElement('span');
|
||||
movieText.textContent = 'Movie: ' + item.movieName;
|
||||
p.appendChild(movieText);
|
||||
|
||||
info.appendChild(p);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const ombiEvents = wh.ombi?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
const ombiPolls = wh.ombi?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
+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,137 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
const logQueue = [];
|
||||
const MAX_QUEUE_SIZE = 20;
|
||||
const FLUSH_INTERVAL_MS = 2000;
|
||||
|
||||
// Original console functions
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
let isSending = false;
|
||||
let isInitialized = false;
|
||||
let flushInterval = null;
|
||||
|
||||
function formatArgs(args) {
|
||||
return args.map(arg => {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (arg instanceof Error) {
|
||||
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function enqueue(level, args) {
|
||||
const formattedMsg = formatArgs(args);
|
||||
|
||||
// Still write to the developer console!
|
||||
if (level === 'info') originalLog.apply(console, args);
|
||||
else if (level === 'warn') originalWarn.apply(console, args);
|
||||
else if (level === 'error') originalError.apply(console, args);
|
||||
|
||||
// Guard against infinite loop during logs dispatching
|
||||
if (isSending) return;
|
||||
|
||||
logQueue.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formattedMsg
|
||||
});
|
||||
|
||||
// Flush immediately if queue is full
|
||||
if (logQueue.length >= MAX_QUEUE_SIZE) {
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushQueue() {
|
||||
if (logQueue.length === 0 || isSending) return;
|
||||
|
||||
isSending = true;
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
// keepalive allows request to survive page unload
|
||||
keepalive: true
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a fast/unblocked payload flush using sendBeacon on page unload
|
||||
function flushOnUnload() {
|
||||
if (logQueue.length === 0) return;
|
||||
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
||||
navigator.sendBeacon('/api/debug/client-logs', blob);
|
||||
} catch (err) {
|
||||
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
|
||||
try {
|
||||
fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch),
|
||||
keepalive: true
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initClientLogCapture() {
|
||||
if (isInitialized) return;
|
||||
|
||||
try {
|
||||
// 1. Check if the server toggle for logging is active
|
||||
const response = await fetch('/api/debug/status');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data && data.enabled === true) {
|
||||
// 2. Override global console methods
|
||||
console.log = (...args) => enqueue('info', args);
|
||||
console.warn = (...args) => enqueue('warn', args);
|
||||
console.error = (...args) => enqueue('error', args);
|
||||
|
||||
// 3. Set interval for batch updates
|
||||
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
||||
|
||||
// 4. Setup beforeunload listener for clean flushing
|
||||
window.addEventListener('beforeunload', flushOnUnload);
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
|
||||
}
|
||||
}
|
||||
@@ -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
+3967
-5
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.14",
|
||||
"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": {
|
||||
@@ -13,7 +13,10 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix",
|
||||
"audit:critical": "npm audit --audit-level=critical"
|
||||
"audit:critical": "npm audit --audit-level=critical",
|
||||
"generate:openapi": "node scripts/generate-openapi.js",
|
||||
"generate:raml": "node scripts/downgrade-openapi.js && node scripts/simple-raml-converter.js",
|
||||
"package:raml": "node scripts/package-raml.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -23,10 +26,15 @@
|
||||
"express-rate-limit": "^7.0.0",
|
||||
"helmet": "^7.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"xmlrpc": "^1.3.2"
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"xmlrpc": "^1.3.2",
|
||||
"yamljs": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stoplight/spectral-cli": "^6.16.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"archiver": "^7.0.1",
|
||||
"concurrently": "^7.6.0",
|
||||
"nock": "^14.0.15",
|
||||
"nodemon": "^3.1.14",
|
||||
|
||||
+20
-20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st0{fill:#24292e}</style><g id="Group-Copy" transform="translate(70 21)"><path id="Shape" d="m10.3 59.8 3.9 372.4c-31.4 3.9-54.9-11.8-54.9-43.1l-3.9-309.7c0-98 90.2-121.5 145.1-82.3l278.3 160.7c39.2 27.4 47 78.4 27.4 113.7-3.9-27.4-15.7-43.1-39.2-58.8L53.4 36.2C29.9 20.6 10.3 24.5 10.3 59.8" class="st0"/><path id="Shape_00000114049535938561773820000018271523940913105341_" d="M-13.2 451.8c23.5 7.8 47 3.9 66.6-7.8l321.5-188.2c19.6 27.4 15.7 54.9-7.8 70.6L96.5 483.2c-39.2 19.6-90.1 0-109.7-31.4" class="st0"/><path id="Shape_00000165935924413286433040000003668002807793862576_" d="M80.9 342 273 232.3 84.8 126.4z" style="fill:#ffc230"/></g></svg>
|
||||
|
After Width: | Height: | Size: 778 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M511.8 256c0 70.4-24.9 130.8-74.6 181.1-1.7 2-3.5 3.8-5.5 5.4-8.2 8-16.8 15.3-26 21.8Q341.05 512 256.3 512c-56.6 0-106.3-15.9-149.2-47.7-11.3-8-22-17.1-31.9-27.3C36.5 398.7 12.8 354 4 303.2c-1.7-9.9-2.9-20-3.4-30.2-.2-5.7-.4-11.3-.4-17 0-6 .1-11.7.4-17.1 0-.6.2-1.1.5-1.7 3.7-62.8 28.4-117 74.1-162.8C125.5 24.8 185.8 0 256.2 0c70.7 0 131 24.8 180.9 74.5q74.7 75.9 74.7 181.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#eee"/><path d="m459.7 100.3-52.9 52.9c-30.9 30.9-33.6 57.8-33.6 105.3 0 42.3 6.7 81.1 38.2 112.6 23 23 44.9 44.7 44.9 44.7-5.9 7.2-12.3 14.3-19.1 21.2-1.7 2-3.5 3.8-5.5 5.4-6 5.9-12.2 11.4-18.6 16.4l-41.4-41.4C334.9 380.6 305.6 377 257 377c-46.7 0-78.4 4.3-112.6 38.5-20.4 20.4-43.8 43.9-43.8 43.9-8.9-6.8-17.3-14.2-25.3-22.4-6.6-6.6-12.8-13.4-18.5-20.3 0 0 23.1-23.2 45.2-45.3 32.7-32.7 38-70.6 38-113 0-41.3-6.8-79.8-36.8-109.9C82.2 127.7 53.3 99 53.3 99c6.7-8.5 14-16.7 21.8-24.5 6.9-6.8 14-13.1 21.2-19l48 48c30.7 30.7 70 38.6 112.4 38.6 43.6 0 82.8-8.4 114.7-40.4C391 82.1 417 56.3 417 56.3c6.8 5.6 13.5 11.6 20.1 18.2 8.3 8.3 15.8 16.9 22.6 25.8" style="fill-rule:evenodd;clip-rule:evenodd;fill:#3a3f51"/><path d="M186 269.1c-.5-2.8-.8-5.5-.9-8.4-.1-1.6-.1-3.1-.1-4.7 0-1.7 0-3.2.1-4.7 0-.2 0-.3.1-.5 1-17.4 7.9-32.4 20.5-45.1 13.9-13.8 30.6-20.7 50.2-20.7s36.3 6.9 50.2 20.7c13.8 14 20.7 30.8 20.7 50.3s-6.9 36.2-20.7 50.2c-.5.5-1 1.1-1.5 1.5q-3.45 3.3-7.2 6-18 13.2-41.4 13.2c-23.4 0-29.4-4.4-41.3-13.2-3.1-2.2-6.1-4.7-8.9-7.6-10.8-10.6-17.3-22.9-19.8-37" style="fill-rule:evenodd;clip-rule:evenodd;fill:#0cf"/><path d="m372.7 141-35.4 34.6M72.9 76.8l96.5 96.1m199.7 198.9 65.6 67.9m4.4-363.3L372.7 141M76.6 438.5l64.6-64.7" style="fill:none;stroke:#0cf;stroke-width:2;stroke-miterlimit:1"/><path d="m372.7 141-40 40.6m-193.3-38.5 40.6 40.5M141 374l39.5-41.1m146.2-3.3 42.6 42.4" style="fill:none;stroke:#0cf;stroke-width:7;stroke-miterlimit:1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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">
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Service Icons ===== */
|
||||
.service-icons-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
height: 1.2em; /* Match text height */
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.service-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== Splash Screen ===== */
|
||||
.splash-screen {
|
||||
position: fixed;
|
||||
@@ -874,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;
|
||||
@@ -2008,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Swagger UI authentication banner
|
||||
// This banner explains the cookie + CSRF authentication flow
|
||||
(function() {
|
||||
window.addEventListener('load', function() {
|
||||
const banner = document.createElement('div');
|
||||
banner.style.cssText = `
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #856404;
|
||||
`;
|
||||
banner.innerHTML = `
|
||||
<strong>Authentication Required for Most Endpoints</strong><br>
|
||||
sofarr uses cookie-based authentication with Emby/Jellyfin. To test authenticated endpoints:<br>
|
||||
1. Call <code>POST /api/auth/login</code> with your username and password<br>
|
||||
2. The server sets an <code>emby_user</code> cookie and <code>csrf_token</code> cookie<br>
|
||||
3. Include these cookies in subsequent requests<br>
|
||||
4. For state-changing operations (POST/PUT/PATCH/DELETE), also send the <code>X-CSRF-Token</code> header<br>
|
||||
<br>
|
||||
<em>Note: The Swagger UI "Authorize" button is not used. Authentication is handled via cookies.</em>
|
||||
`;
|
||||
|
||||
// Insert after the topbar (which we hide with CSS) or at the top of the info section
|
||||
const info = document.querySelector('.info');
|
||||
if (info) {
|
||||
info.insertBefore(banner, info.firstChild);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Converts OpenAPI 3.0 to RAML 1.0 using AMF (amf-client-js)
|
||||
* AMF is the modern replacement for deprecated RAML converters.
|
||||
*/
|
||||
|
||||
const { Main, AMFParser, AMFTransformer } = require('amf-client-js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
|
||||
const OUTPUT_FILE = path.join(process.cwd(), 'dist/api.raml');
|
||||
|
||||
async function convertToRaml() {
|
||||
if (!fs.existsSync(INPUT_FILE)) {
|
||||
throw new Error(`Input file not found: ${INPUT_FILE}`);
|
||||
}
|
||||
|
||||
console.log('Initializing AMF...');
|
||||
await Main.init();
|
||||
|
||||
console.log(`Reading OpenAPI 3.0 spec from ${INPUT_FILE}`);
|
||||
const specContent = fs.readFileSync(INPUT_FILE, 'utf-8');
|
||||
|
||||
console.log('Parsing OpenAPI spec...');
|
||||
const parser = new AMFParser();
|
||||
const model = await parser.parseStringAsync('file://' + INPUT_FILE, specContent, 'application/json');
|
||||
|
||||
console.log('Resolving references...');
|
||||
const resolvedModel = await AMFTransformer.resolve(model);
|
||||
|
||||
console.log('Converting to RAML 1.0...');
|
||||
const ramlModel = await AMFTransformer.transform(resolvedModel, 'RAML 1.0');
|
||||
|
||||
console.log('Generating RAML output...');
|
||||
const ramlContent = await AMFTransformer.generateString(ramlModel, 'application/yaml');
|
||||
|
||||
// Clean up the output - AMF sometimes adds extra formatting
|
||||
const cleanedRaml = ramlContent
|
||||
.replace('#%RAML 1.0\n', '#%RAML 1.0\n\n')
|
||||
.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, cleanedRaml);
|
||||
console.log(`✓ RAML spec written to ${OUTPUT_FILE}`);
|
||||
|
||||
// Basic validation
|
||||
if (!cleanedRaml.includes('#%RAML 1.0')) {
|
||||
throw new Error('Generated RAML does not appear to be valid RAML 1.0');
|
||||
}
|
||||
|
||||
console.log('RAML conversion complete');
|
||||
}
|
||||
|
||||
convertToRaml()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to convert to RAML:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Downgrades OpenAPI 3.1.0 to 3.0.0 for compatibility with RAML converters.
|
||||
* OpenAPI 3.1 has limited support in existing RAML conversion tools.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-merged.json');
|
||||
const OUTPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
|
||||
|
||||
function downgradeOpenApi30(spec) {
|
||||
// Change version from 3.1.0 to 3.0.0
|
||||
spec.openapi = '3.0.0';
|
||||
|
||||
// OpenAPI 3.1 uses "type" with array for nullable, 3.0 uses nullable: true
|
||||
// This is a simple pass-through for now - complex schemas may need more handling
|
||||
// For this spec, most nullable fields are already using 3.0-compatible syntax
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(INPUT_FILE)) {
|
||||
throw new Error(`Input file not found: ${INPUT_FILE}`);
|
||||
}
|
||||
|
||||
console.log(`Reading OpenAPI 3.1 spec from ${INPUT_FILE}`);
|
||||
const spec = JSON.parse(fs.readFileSync(INPUT_FILE, 'utf-8'));
|
||||
|
||||
console.log('Downgrading to OpenAPI 3.0.0...');
|
||||
const downgraded = downgradeOpenApi30(spec);
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(downgraded, null, 2));
|
||||
console.log(`✓ Downgraded spec written to ${OUTPUT_FILE}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.log('Downgrade complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to downgrade spec:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Generates the merged OpenAPI spec by bootstrapping the Express app
|
||||
* and fetching the spec from /api/swagger.json.
|
||||
*
|
||||
* This ensures the generated spec matches exactly what users see in production.
|
||||
*/
|
||||
|
||||
const { createApp } = require('../server/app.js');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 34567; // Use a different port to avoid conflicts
|
||||
const OUTPUT_DIR = path.join(process.cwd(), 'dist');
|
||||
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'openapi-merged.json');
|
||||
|
||||
async function generateOpenApiSpec() {
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('Bootstrapping Express app in test mode...');
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on port ${PORT}`);
|
||||
|
||||
// Fetch the merged spec
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: PORT,
|
||||
path: '/api/swagger.json',
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const spec = JSON.parse(data);
|
||||
|
||||
// Validate it's a proper OpenAPI spec
|
||||
if (!spec.openapi || !spec.info) {
|
||||
throw new Error('Invalid OpenAPI spec: missing openapi or info field');
|
||||
}
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(spec, null, 2));
|
||||
console.log(`✓ OpenAPI spec written to ${OUTPUT_FILE}`);
|
||||
console.log(` Version: ${spec.openapi}`);
|
||||
console.log(` Title: ${spec.info.title}`);
|
||||
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing OpenAPI spec:', error.message);
|
||||
server.close(() => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error('Error fetching spec:', error.message);
|
||||
server.close(() => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${PORT} is already in use`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
generateOpenApiSpec()
|
||||
.then(() => {
|
||||
console.log('OpenAPI spec generation complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to generate OpenAPI spec:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generateOpenApiSpec };
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Creates a versioned tar.gz archive containing the RAML spec,
|
||||
* original OpenAPI spec, version metadata, and README.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist');
|
||||
const RAML_FILE = path.join(DIST_DIR, 'api.raml');
|
||||
const OPENAPI_FILE = path.join(DIST_DIR, 'openapi-merged.json');
|
||||
|
||||
function getVersion() {
|
||||
try {
|
||||
// Try to get the exact tag if we're on one
|
||||
const tag = execSync('git describe --tags --exact-match 2>/dev/null', { encoding: 'utf-8' }).trim();
|
||||
if (tag) return tag;
|
||||
} catch (e) {
|
||||
// Not on a tag, fall back to SHA
|
||||
}
|
||||
|
||||
try {
|
||||
// Get short commit SHA
|
||||
const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
||||
return sha;
|
||||
} catch (e) {
|
||||
// Not in a git repo, use timestamp
|
||||
return `dev-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitSha() {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function createVersionJson(version, commitSha) {
|
||||
return {
|
||||
version,
|
||||
commit: commitSha,
|
||||
generatedAt: new Date().toISOString(),
|
||||
tool: 'oas3-to-raml',
|
||||
openapiVersion: '3.1.0',
|
||||
ramlVersion: '1.0'
|
||||
};
|
||||
}
|
||||
|
||||
function createReadme(version, commitSha) {
|
||||
return `# sofarr RAML 1.0 Specification
|
||||
|
||||
## Origin
|
||||
|
||||
This RAML specification was automatically generated from the sofarr OpenAPI 3.1.0 specification.
|
||||
|
||||
- **Version:** ${version}
|
||||
- **Commit:** ${commitSha}
|
||||
- **Generated At:** ${new Date().toISOString()}
|
||||
- **Conversion Tool:** oas3-to-raml (npx)
|
||||
|
||||
## Contents
|
||||
|
||||
- \`api.raml\` - The RAML 1.0 specification
|
||||
- \`openapi-merged.json\` - Original merged OpenAPI 3.1.0 spec (for reference)
|
||||
- \`version.json\` - Metadata about this generation
|
||||
|
||||
## Known Limitations
|
||||
|
||||
This RAML spec was converted from OpenAPI 3.1.0. Some OpenAPI 3.1 features may not translate perfectly to RAML 1.0:
|
||||
|
||||
- Cookie-based authentication (CookieAuth) may require manual mapping to RAML security schemes
|
||||
- Advanced schema features (e.g., certain keywords, complex polymorphism) may be approximated or dropped
|
||||
- Webhook-specific features may not be fully represented
|
||||
|
||||
For the most accurate API documentation, refer to the live Swagger UI at \`/api/swagger\` or the original OpenAPI spec included in this archive.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Validate the RAML spec:
|
||||
\`\`\`bash
|
||||
npx raml-1-parser validate api.raml
|
||||
\`\`\`
|
||||
|
||||
2. Compare endpoints with the live Swagger UI at \`/api/swagger\`
|
||||
|
||||
3. Test in a RAML-aware tool (e.g., API Workbench, MuleSoft Anypoint)
|
||||
|
||||
## Quick Start
|
||||
|
||||
To use this RAML spec:
|
||||
|
||||
1. Extract the archive
|
||||
2. Open \`api.raml\` in your preferred RAML tool
|
||||
3. For development, import it into API Workbench or similar tools
|
||||
|
||||
## Source
|
||||
|
||||
This artifact was generated from the sofarr project:
|
||||
https://git.i3omb.com/Gandalf/sofarr
|
||||
|
||||
Generated from CI run on commit ${commitSha}.
|
||||
`;
|
||||
}
|
||||
|
||||
async function packageRaml() {
|
||||
const version = getVersion();
|
||||
const commitSha = getCommitSha();
|
||||
const archiveName = `raml-${version}`;
|
||||
const archivePath = path.join(DIST_DIR, `${archiveName}.tar.gz`);
|
||||
const stagingDir = path.join(DIST_DIR, archiveName);
|
||||
|
||||
console.log(`Packaging RAML for version: ${version}`);
|
||||
console.log(`Commit: ${commitSha}`);
|
||||
|
||||
// Check that required files exist
|
||||
if (!fs.existsSync(RAML_FILE)) {
|
||||
throw new Error(`RAML file not found: ${RAML_FILE}`);
|
||||
}
|
||||
if (!fs.existsSync(OPENAPI_FILE)) {
|
||||
throw new Error(`OpenAPI file not found: ${OPENAPI_FILE}`);
|
||||
}
|
||||
|
||||
// Create staging directory
|
||||
if (fs.existsSync(stagingDir)) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(stagingDir, { recursive: true });
|
||||
|
||||
// Copy files to staging directory
|
||||
fs.copyFileSync(RAML_FILE, path.join(stagingDir, 'api.raml'));
|
||||
fs.copyFileSync(OPENAPI_FILE, path.join(stagingDir, 'openapi-merged.json'));
|
||||
|
||||
// Create version.json
|
||||
const versionJson = createVersionJson(version, commitSha);
|
||||
fs.writeFileSync(path.join(stagingDir, 'version.json'), JSON.stringify(versionJson, null, 2));
|
||||
|
||||
// Create README.md
|
||||
const readme = createReadme(version, commitSha);
|
||||
fs.writeFileSync(path.join(stagingDir, 'README.md'), readme);
|
||||
|
||||
// Create tar.gz archive
|
||||
console.log(`Creating archive: ${archivePath}`);
|
||||
const output = fs.createWriteStream(archivePath);
|
||||
const archive = archiver('tar', { gzip: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
output.on('close', () => {
|
||||
console.log(`✓ Archive created: ${archivePath}`);
|
||||
console.log(` Size: ${archive.pointer()} bytes`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(stagingDir, false);
|
||||
archive.finalize();
|
||||
}).then(() => {
|
||||
// Clean up staging directory
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
packageRaml()
|
||||
.then(() => {
|
||||
console.log('RAML packaging complete');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to package RAML:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { packageRaml };
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Simple OpenAPI 3.0 to RAML 1.0 converter.
|
||||
* This is a basic converter that handles the essential parts of the sofarr API.
|
||||
* For a production system, you'd want a more sophisticated converter.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INPUT_FILE = path.join(process.cwd(), 'dist/openapi-30.json');
|
||||
const OUTPUT_FILE = path.join(process.cwd(), 'dist/api.raml');
|
||||
|
||||
function convertToRaml(spec) {
|
||||
const lines = [];
|
||||
|
||||
// RAML header
|
||||
lines.push('#%RAML 1.0');
|
||||
lines.push('');
|
||||
|
||||
// Title and version
|
||||
lines.push(`title: ${spec.info.title}`);
|
||||
if (spec.info.version) {
|
||||
lines.push(`version: ${spec.info.version}`);
|
||||
}
|
||||
if (spec.info.description) {
|
||||
lines.push(`description: |`);
|
||||
spec.info.description.split('\n').forEach(line => {
|
||||
lines.push(` ${line}`);
|
||||
});
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Base URI
|
||||
if (spec.servers && spec.servers.length > 0) {
|
||||
lines.push(`baseUri: ${spec.servers[0].url}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Security Schemes
|
||||
if (spec.components && spec.components.securitySchemes) {
|
||||
lines.push('securitySchemes:');
|
||||
for (const [name, scheme] of Object.entries(spec.components.securitySchemes)) {
|
||||
lines.push(` ${name}:`);
|
||||
if (scheme.type === 'apiKey') {
|
||||
lines.push(` type: Api Key`);
|
||||
lines.push(` describedBy:`);
|
||||
lines.push(` headers:`);
|
||||
lines.push(` Authorization:`);
|
||||
lines.push(` description: API key for authentication`);
|
||||
lines.push(` type: string`);
|
||||
} else if (scheme.type === 'http' && scheme.scheme === 'bearer') {
|
||||
lines.push(` type: OAuth 2.0`);
|
||||
lines.push(` settings:`);
|
||||
lines.push(` authorizationUri: ${scheme.bearerFormat || 'Bearer'}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Types (schemas)
|
||||
if (spec.components && spec.components.schemas) {
|
||||
lines.push('types:');
|
||||
for (const [name, schema] of Object.entries(spec.components.schemas)) {
|
||||
lines.push(` ${name}:`);
|
||||
if (schema.type === 'object') {
|
||||
lines.push(` type: object`);
|
||||
if (schema.properties) {
|
||||
lines.push(` properties:`);
|
||||
for (const [propName, prop] of Object.entries(schema.properties)) {
|
||||
lines.push(` ${propName}:`);
|
||||
lines.push(` type: ${mapJsonTypeToRaml(prop.type || 'string')}`);
|
||||
if (prop.description) {
|
||||
lines.push(` description: ${prop.description}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(` type: ${mapJsonTypeToRaml(schema.type || 'string')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Paths
|
||||
if (spec.paths) {
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
lines.push(`/${path.replace(/^\//, '')}:`);
|
||||
|
||||
// Methods
|
||||
for (const [method, operation] of Object.entries(pathItem)) {
|
||||
if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) {
|
||||
lines.push(` ${method}:`);
|
||||
if (operation.summary) {
|
||||
lines.push(` displayName: ${operation.summary}`);
|
||||
}
|
||||
if (operation.description) {
|
||||
lines.push(` description: |`);
|
||||
operation.description.split('\n').forEach(line => {
|
||||
lines.push(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Query parameters
|
||||
if (operation.parameters) {
|
||||
const queryParams = operation.parameters.filter(p => p.in === 'query');
|
||||
if (queryParams.length > 0) {
|
||||
lines.push(` queryParameters:`);
|
||||
queryParams.forEach(param => {
|
||||
lines.push(` ${param.name}:`);
|
||||
lines.push(` type: ${mapJsonTypeToRaml(param.schema?.type || 'string')}`);
|
||||
lines.push(` required: ${param.required || false}`);
|
||||
if (param.description) {
|
||||
lines.push(` description: ${param.description}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Responses
|
||||
if (operation.responses) {
|
||||
lines.push(` responses:`);
|
||||
for (const [code, response] of Object.entries(operation.responses)) {
|
||||
lines.push(` ${code}:`);
|
||||
if (response.description) {
|
||||
lines.push(` description: ${response.description}`);
|
||||
}
|
||||
if (response.content && response.content['application/json']) {
|
||||
const schema = response.content['application/json'].schema;
|
||||
if (schema && schema.$ref) {
|
||||
const refName = schema.$ref.replace('#/components/schemas/', '');
|
||||
lines.push(` body:`);
|
||||
lines.push(` application/json:`);
|
||||
lines.push(` type: ${refName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function mapJsonTypeToRaml(jsonType) {
|
||||
const typeMap = {
|
||||
'string': 'string',
|
||||
'integer': 'integer',
|
||||
'number': 'number',
|
||||
'boolean': 'boolean',
|
||||
'array': 'array',
|
||||
'object': 'object'
|
||||
};
|
||||
return typeMap[jsonType] || 'string';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(INPUT_FILE)) {
|
||||
throw new Error(`Input file not found: ${INPUT_FILE}`);
|
||||
}
|
||||
|
||||
console.log(`Reading OpenAPI 3.0 spec from ${INPUT_FILE}`);
|
||||
const spec = JSON.parse(fs.readFileSync(INPUT_FILE, 'utf-8'));
|
||||
|
||||
console.log('Converting to RAML 1.0...');
|
||||
const ramlContent = convertToRaml(spec);
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, ramlContent);
|
||||
console.log(`✓ RAML spec written to ${OUTPUT_FILE}`);
|
||||
console.log('RAML conversion complete');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to convert to RAML:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+125
-1
@@ -11,6 +11,11 @@ const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const crypto = require('crypto');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
const path = require('path');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
@@ -21,11 +26,31 @@ 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 debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
const app = express();
|
||||
|
||||
// Load OpenAPI spec from YAML
|
||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
||||
|
||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
||||
const swaggerOptions = {
|
||||
definition: {
|
||||
...openapiSpec,
|
||||
openapi: '3.1.0'
|
||||
},
|
||||
apis: [
|
||||
path.join(__dirname, 'routes/*.js'),
|
||||
path.join(__dirname, 'app.js'),
|
||||
path.join(__dirname, 'index.js')
|
||||
]
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
||||
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
||||
? parseInt(process.env.TRUST_PROXY, 10)
|
||||
@@ -73,6 +98,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
@@ -80,10 +106,79 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
app.use(express.json({ limit: '64kb' }));
|
||||
|
||||
// Health / readiness (no auth, no rate-limit)
|
||||
/**
|
||||
* @openapi
|
||||
* /health:
|
||||
* get:
|
||||
* tags: [Health]
|
||||
* summary: Health check
|
||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "ok"
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: Server uptime in seconds
|
||||
* example: 3600.5
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.14"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: curl http://localhost:3001/health
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ready:
|
||||
* get:
|
||||
* tags: [Health]
|
||||
* summary: Readiness check
|
||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Server is ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "ready"
|
||||
* '503':
|
||||
* description: Server not ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "not ready"
|
||||
* reason:
|
||||
* type: string
|
||||
* example: "EMBY_URL not configured"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: curl http://localhost:3001/ready
|
||||
*/
|
||||
app.get('/ready', (req, res) => {
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
if (ready) {
|
||||
@@ -93,10 +188,38 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Swagger UI - publicly accessible API documentation
|
||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
||||
customSiteTitle: 'sofarr API Documentation',
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customJs: [
|
||||
'/swagger-auth-banner.js'
|
||||
],
|
||||
swaggerOptions: {
|
||||
url: '/api/swagger.json'
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
||||
app.get('/api/swagger.json', (req, res) => {
|
||||
// Clone the spec to avoid modifying the original
|
||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
||||
|
||||
// Replace the server URL with the current request's origin
|
||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
res.json(specCopy);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
@@ -104,6 +227,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);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Ombi API client for fetching requests and searching media.
|
||||
* Provides integration with Ombi request management system.
|
||||
*/
|
||||
class OmbiClient {
|
||||
constructor(url, apiKey) {
|
||||
this.url = url.replace(/\/$/, ''); // Remove trailing slash
|
||||
this.apiKey = apiKey;
|
||||
this.axios = axios.create({
|
||||
headers: { 'ApiKey': this.apiKey },
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all movie requests from Ombi
|
||||
* @returns {Promise<Array>} Array of movie request objects
|
||||
*/
|
||||
async getMovieRequests() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Movie requests error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV requests from Ombi
|
||||
* @returns {Promise<Array>} Array of TV request objects
|
||||
*/
|
||||
async getTvRequests() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Request/tv`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] TV requests error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for movies by TMDB ID
|
||||
* @param {string} tmdbId - TheMovieDB ID
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchMovieByTmdbId(tmdbId) {
|
||||
if (!tmdbId) return null;
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/${tmdbId}`);
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Movie search error for TMDB ID ${tmdbId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for movies by IMDB ID
|
||||
* @param {string} imdbId - IMDB ID
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchMovieByImdbId(imdbId) {
|
||||
if (!imdbId) return null;
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Search/movie/imdb/${imdbId}`);
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Movie search error for IMDB ID ${imdbId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TV shows by TVDB ID
|
||||
* @param {string} tvdbId - TheTVDB ID
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchTvByTvdbId(tvdbId) {
|
||||
if (!tvdbId) return null;
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/${tvdbId}`);
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] TV search error for TVDB ID ${tvdbId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TV shows by TMDB ID
|
||||
* @param {string} tmdbId - TheMovieDB ID
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchTvByTmdbId(tmdbId) {
|
||||
if (!tmdbId) return null;
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Search/tv/tmdb/${tmdbId}`);
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] TV search error for TMDB ID ${tmdbId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Ombi API
|
||||
* @returns {Promise<boolean>} True if connection is successful
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Request/movie`);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OmbiClient;
|
||||
@@ -0,0 +1,263 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const ArrRetriever = require('./ArrRetriever');
|
||||
const OmbiClient = require('./OmbiClient');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Ombi data retriever with caching support.
|
||||
* Extends ArrRetriever for PALDRA compliance.
|
||||
* Manages Ombi request data and provides lookup maps for efficient matching.
|
||||
*/
|
||||
class OmbiRetriever extends ArrRetriever {
|
||||
constructor(instanceConfig) {
|
||||
super(instanceConfig);
|
||||
this.client = new OmbiClient(this.url, this.apiKey);
|
||||
this.baseUrl = this.url;
|
||||
this.cache = {
|
||||
movieRequests: [],
|
||||
tvRequests: [],
|
||||
movieMap: new Map(), // tmdbId -> request
|
||||
tvMap: new Map(), // tvdbId -> request
|
||||
lastFetch: 0,
|
||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retriever type
|
||||
* @returns {string} The retriever type
|
||||
*/
|
||||
getRetrieverType() {
|
||||
return 'ombi';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique instance ID
|
||||
* @returns {string} The instance ID
|
||||
*/
|
||||
getInstanceId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags from Ombi (not applicable, returns empty array)
|
||||
* @returns {Promise<Array>} Empty array (Ombi doesn't have tags)
|
||||
*/
|
||||
async getTags() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue from Ombi (active requests)
|
||||
* @returns {Promise<Object>} Queue object with records array
|
||||
*/
|
||||
async getQueue() {
|
||||
await this.refreshCache();
|
||||
return {
|
||||
records: [...this.cache.movieRequests, ...this.cache.tvRequests]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history from Ombi (not applicable, returns empty records)
|
||||
* @param {Object} options - Optional parameters (ignored for Ombi)
|
||||
* @returns {Promise<Object>} History object with empty records array
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
return {
|
||||
records: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Ombi
|
||||
* @returns {Promise<boolean>} True if connection is successful
|
||||
*/
|
||||
async testConnection() {
|
||||
return await this.client.testConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is expired
|
||||
* @returns {boolean} True if cache needs refresh
|
||||
*/
|
||||
isCacheExpired() {
|
||||
return Date.now() - this.cache.lastFetch > this.cache.ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh cached data from Ombi API
|
||||
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshCache(force = false) {
|
||||
if (!force && !this.isCacheExpired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logToFile('[OmbiRetriever] Refreshing cache');
|
||||
|
||||
// Fetch requests in parallel
|
||||
const [movieRequests, tvRequests] = await Promise.all([
|
||||
this.client.getMovieRequests(),
|
||||
this.client.getTvRequests()
|
||||
]);
|
||||
|
||||
// Update cache
|
||||
this.cache.movieRequests = movieRequests;
|
||||
this.cache.tvRequests = tvRequests;
|
||||
this.cache.lastFetch = Date.now();
|
||||
|
||||
// Build lookup maps
|
||||
this.cache.movieMap.clear();
|
||||
this.cache.tvMap.clear();
|
||||
|
||||
// Build movie map (tmdbId -> request)
|
||||
movieRequests.forEach(request => {
|
||||
if (request.theMovieDbId) {
|
||||
this.cache.movieMap.set(request.theMovieDbId, request);
|
||||
}
|
||||
if (request.imdbId) {
|
||||
this.cache.movieMap.set(request.imdbId, request);
|
||||
}
|
||||
});
|
||||
|
||||
// Build TV map (tvdbId -> request, fallback to tmdbId)
|
||||
tvRequests.forEach(request => {
|
||||
if (request.theTvDbId) {
|
||||
this.cache.tvMap.set(request.theTvDbId, request);
|
||||
}
|
||||
if (request.theMovieDbId) {
|
||||
this.cache.tvMap.set(request.theMovieDbId, request);
|
||||
}
|
||||
});
|
||||
|
||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||
// Don't throw error, continue with stale cache if available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all movie requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of movie request objects
|
||||
*/
|
||||
async getMovieRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.movieRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of TV request objects
|
||||
*/
|
||||
async getTvRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.tvRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find movie request by external ID
|
||||
* @param {string} tmdbId - TheMovieDB ID
|
||||
* @param {string} imdbId - IMDB ID (optional fallback)
|
||||
* @returns {Promise<Object|null>} Request object or null if not found
|
||||
*/
|
||||
async findMovieRequest(tmdbId, imdbId = null) {
|
||||
await this.refreshCache();
|
||||
|
||||
// Try TMDB ID first
|
||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||
return this.cache.movieMap.get(tmdbId);
|
||||
}
|
||||
|
||||
// Try IMDB ID as fallback
|
||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||
return this.cache.movieMap.get(imdbId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find TV request by external ID
|
||||
* @param {string} tvdbId - TheTVDB ID
|
||||
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
|
||||
* @returns {Promise<Object|null>} Request object or null if not found
|
||||
*/
|
||||
async findTvRequest(tvdbId, tmdbId = null) {
|
||||
await this.refreshCache();
|
||||
|
||||
// Try TVDB ID first
|
||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||
return this.cache.tvMap.get(tvdbId);
|
||||
}
|
||||
|
||||
// Try TMDB ID as fallback
|
||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||
return this.cache.tvMap.get(tmdbId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for movie by external ID (for fallback when no request found)
|
||||
* @param {string} tmdbId - TheMovieDB ID
|
||||
* @param {string} imdbId - IMDB ID (optional fallback)
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchMovie(tmdbId, imdbId = null) {
|
||||
if (tmdbId) {
|
||||
const result = await this.client.searchMovieByTmdbId(tmdbId);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (imdbId) {
|
||||
const result = await this.client.searchMovieByImdbId(imdbId);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for TV show by external ID (for fallback when no request found)
|
||||
* @param {string} tvdbId - TheTVDB ID
|
||||
* @param {string} tmdbId - TheMovieDB ID (optional fallback)
|
||||
* @returns {Promise<Object|null>} Search result object or null if not found
|
||||
*/
|
||||
async searchTv(tvdbId, tmdbId = null) {
|
||||
if (tvdbId) {
|
||||
const result = await this.client.searchTvByTvdbId(tvdbId);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
const result = await this.client.searchTvByTmdbId(tmdbId);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} Cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return {
|
||||
movieRequests: this.cache.movieRequests.length,
|
||||
tvRequests: this.cache.tvRequests.length,
|
||||
movieMapSize: this.cache.movieMap.size,
|
||||
tvMapSize: this.cache.tvMap.size,
|
||||
lastFetch: this.cache.lastFetch,
|
||||
age: Date.now() - this.cache.lastFetch
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OmbiRetriever;
|
||||
@@ -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
|
||||
|
||||
+117
@@ -8,8 +8,13 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const logCapture = require('./utils/logCapture');
|
||||
logCapture.init();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -86,6 +91,8 @@ 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 debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
@@ -113,6 +120,23 @@ if (process.env.EMBY_URL) {
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Load OpenAPI spec from YAML
|
||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
||||
|
||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
||||
const swaggerOptions = {
|
||||
definition: {
|
||||
...openapiSpec,
|
||||
openapi: '3.1.0'
|
||||
},
|
||||
apis: [
|
||||
path.join(__dirname, 'routes/*.js'),
|
||||
path.join(__dirname, 'index.js')
|
||||
]
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
||||
|
||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
@@ -185,6 +209,7 @@ const apiLimiter = rateLimit({
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
@@ -198,10 +223,71 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
// Health / readiness endpoints (no auth, no rate-limit)
|
||||
// Used by Docker HEALTHCHECK and orchestrators.
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* @openapi
|
||||
* /health:
|
||||
* get:
|
||||
* tags: [Health]
|
||||
* summary: Health check
|
||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Server is healthy
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "ok"
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: Server uptime in seconds
|
||||
* example: 3600.5
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.14"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ready:
|
||||
* get:
|
||||
* tags: [Health]
|
||||
* summary: Readiness check
|
||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Server is ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "ready"
|
||||
* '503':
|
||||
* description: Server not ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "not ready"
|
||||
* reason:
|
||||
* type: string
|
||||
* example: "EMBY_URL not configured"
|
||||
*/
|
||||
app.get('/ready', (req, res) => {
|
||||
// Confirm critical config is present
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
@@ -212,6 +298,35 @@ app.get('/ready', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger UI - publicly accessible API documentation
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
||||
customSiteTitle: 'sofarr API Documentation',
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customJs: [
|
||||
'/swagger-auth-banner.js'
|
||||
],
|
||||
swaggerOptions: {
|
||||
url: '/api/swagger.json'
|
||||
}
|
||||
}));
|
||||
|
||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
||||
app.get('/api/swagger.json', (req, res) => {
|
||||
// Clone the spec to avoid modifying the original
|
||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
||||
|
||||
// Replace the server URL with the current request's origin
|
||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
res.json(specCopy);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static files — served before API routes
|
||||
// index.html is served manually so we can inject the CSP nonce
|
||||
@@ -255,6 +370,7 @@ function serveIndex(req, res) {
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
app.use('/api', verifyCsrf);
|
||||
@@ -262,6 +378,7 @@ app.use('/api/sabnzbd', sabnzbdRoutes);
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/ombi', ombiRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/status', statusRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const ipaddr = require('ipaddr.js');
|
||||
|
||||
function getEmbyUrl() {
|
||||
return process.env.EMBY_URL;
|
||||
}
|
||||
|
||||
function isIpAllowed(clientIp, allowedSubnetsStr) {
|
||||
if (!allowedSubnetsStr) return true;
|
||||
try {
|
||||
const clientIpParsed = ipaddr.parse(clientIp);
|
||||
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const subnet of subnets) {
|
||||
let rangeStr = subnet;
|
||||
let bits = null;
|
||||
if (subnet.includes('/')) {
|
||||
const parts = subnet.split('/');
|
||||
rangeStr = parts[0];
|
||||
bits = parseInt(parts[1], 10);
|
||||
}
|
||||
|
||||
const rangeIpParsed = ipaddr.parse(rangeStr);
|
||||
|
||||
if (bits === null) {
|
||||
// Exact IP match
|
||||
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
// Handle IPv4 mapped IPv6 address case
|
||||
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match with subnet bits
|
||||
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
|
||||
if (clientIpParsed.match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||
// Handle IPv4 mapped IPv6 address case matching IPv4 range
|
||||
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function logStreamAuth(req, res, next) {
|
||||
// 1. Subnet IP Filtering (First Priority)
|
||||
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
|
||||
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
|
||||
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
|
||||
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
|
||||
}
|
||||
|
||||
// 2. Webhook Secret Bypass (High Priority)
|
||||
const secretHeader = req.headers['x-webhook-secret'];
|
||||
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
|
||||
if (configuredSecret && secretHeader === configuredSecret) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 3. Session Cookie
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (rawCookie && rawCookie !== false) {
|
||||
try {
|
||||
const u = JSON.parse(rawCookie);
|
||||
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
|
||||
req.user = u;
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, fallback to basic auth
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Basic Authentication Fallback
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||
try {
|
||||
const credentialsBase64 = authHeader.substring(6);
|
||||
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
|
||||
const colonIdx = credentialsStr.indexOf(':');
|
||||
|
||||
if (colonIdx !== -1) {
|
||||
const username = credentialsStr.substring(0, colonIdx).trim();
|
||||
const password = credentialsStr.substring(colonIdx + 1);
|
||||
|
||||
if (username && password) {
|
||||
const embyUrl = getEmbyUrl();
|
||||
if (!embyUrl) {
|
||||
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Authentication service unavailable' });
|
||||
}
|
||||
|
||||
// Authenticate with Emby using stable DeviceId derived from username
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
|
||||
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
|
||||
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
const userId = authData.User.Id || authData.User.id;
|
||||
|
||||
// Fetch detailed profile to verify administrator status
|
||||
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': authData.AccessToken
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||
|
||||
if (isAdmin) {
|
||||
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
|
||||
req.user = { id: user.Id, name: user.Name, isAdmin: true };
|
||||
return next();
|
||||
} else {
|
||||
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[logStreamAuth] Emby authentication error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Unauthorized / Access Denied
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
module.exports = logStreamAuth;
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
+1927
File diff suppressed because it is too large
Load Diff
+356
-5
@@ -24,7 +24,159 @@ const loginLimiter = rateLimit({
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
// Authenticate user with Emby
|
||||
/**
|
||||
* @openapi
|
||||
* /api/auth/login:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: Authenticate with Emby/Jellyfin
|
||||
* description: |
|
||||
* Authenticates a user against Emby/Jellyfin and sets session cookies.
|
||||
*
|
||||
* **Rate Limiting:** 10 failed attempts per 15 minutes per IP (successful attempts don't count).
|
||||
*
|
||||
* **Authentication Flow:**
|
||||
* 1. Send username and password in request body
|
||||
* 2. Server validates credentials with Emby/Jellyfin
|
||||
* 3. Server sets httpOnly signed cookie `emby_user` containing {id, name, isAdmin}
|
||||
* 4. Server sets `csrf_token` cookie (readable by JS for double-submit pattern)
|
||||
* 5. Response includes user data and CSRF token
|
||||
*
|
||||
* **Cookie Details:**
|
||||
* - `emby_user`: httpOnly, signed, sameSite=strict. Persistent if rememberMe=true (30 days), otherwise session cookie.
|
||||
* - `csrf_token`: httpOnly=false (JS-readable), sameSite=strict. Used for state-changing requests.
|
||||
*
|
||||
* **Security Notes:**
|
||||
* - Password must be 1-256 characters
|
||||
* - Username must be 1-128 characters
|
||||
* - Server rejects with 400 if input validation fails
|
||||
* - Server rejects with 401 if Emby authentication fails
|
||||
*
|
||||
* **x-integration-notes:** After successful login, subsequent requests must:
|
||||
* - Send the emby_user cookie (automatically by browser)
|
||||
* - Send the X-CSRF-Token header (from csrf_token cookie) for POST/PUT/PATCH/DELETE requests
|
||||
* - Use credentials: 'include' in fetch/axios to send cookies
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* maxLength: 128
|
||||
* description: Emby/Jellyfin username
|
||||
* example: "john"
|
||||
* password:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* maxLength: 256
|
||||
* description: Emby/Jellyfin password
|
||||
* example: "password123"
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* description: If true, cookie persists for 30 days; otherwise session cookie
|
||||
* example: false
|
||||
* example:
|
||||
* username: "john"
|
||||
* password: "password123"
|
||||
* rememberMe: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Login successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* user:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Emby user ID
|
||||
* example: "abc123def456"
|
||||
* name:
|
||||
* type: string
|
||||
* description: Display name
|
||||
* example: "John Doe"
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* description: Admin flag
|
||||
* example: false
|
||||
* csrfToken:
|
||||
* type: string
|
||||
* description: CSRF token for state-changing requests
|
||||
* example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
* example:
|
||||
* success: true
|
||||
* user:
|
||||
* id: "abc123def456"
|
||||
* name: "John Doe"
|
||||
* isAdmin: false
|
||||
* csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
* '400':
|
||||
* description: Invalid input (username or password fails validation)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* success: false
|
||||
* error: "Invalid username"
|
||||
* '401':
|
||||
* description: Invalid credentials (Emby authentication failed)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* success: false
|
||||
* error: "Invalid username or password"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X POST http://localhost:3001/api/auth/login \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -c cookies.txt \
|
||||
* -d '{"username":"john","password":"password123"}'
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/auth/login', {
|
||||
* method: 'POST',
|
||||
* headers: { 'Content-Type': 'application/json' },
|
||||
* credentials: 'include',
|
||||
* body: JSON.stringify({ username: 'john', password: 'password123' })
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* console.log(data.csrfToken); // Save this for subsequent requests
|
||||
* - lang: TypeScript
|
||||
* label: TypeScript
|
||||
* source: |
|
||||
* interface LoginResponse {
|
||||
* success: boolean;
|
||||
* user: { id: string; name: string; isAdmin: boolean };
|
||||
* csrfToken: string;
|
||||
* }
|
||||
* const response = await fetch('http://localhost:3001/api/auth/login', {
|
||||
* method: 'POST',
|
||||
* headers: { 'Content-Type': 'application/json' },
|
||||
* credentials: 'include',
|
||||
* body: JSON.stringify({ username: 'john', password: 'password123' })
|
||||
* });
|
||||
* const data: LoginResponse = await response.json();
|
||||
*/
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password, rememberMe } = req.body;
|
||||
@@ -129,7 +281,80 @@ function parseSessionCookie(req) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current authenticated user
|
||||
/**
|
||||
* @openapi
|
||||
* /api/auth/me:
|
||||
* get:
|
||||
* tags: [Auth]
|
||||
* summary: Get current authenticated user
|
||||
* description: |
|
||||
* Returns the currently authenticated user from the session cookie.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie.
|
||||
*
|
||||
* **Response:**
|
||||
* - If authenticated: returns user data (id, name, isAdmin)
|
||||
* - If not authenticated: returns authenticated: false
|
||||
*
|
||||
* **Use Case:** Check if user is logged in and get user details without re-authenticating.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: User data (authenticated or not)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* oneOf:
|
||||
* - type: object
|
||||
* properties:
|
||||
* authenticated:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* user:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* example: "abc123def456"
|
||||
* name:
|
||||
* type: string
|
||||
* example: "John Doe"
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* - type: object
|
||||
* properties:
|
||||
* authenticated:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* examples:
|
||||
* authenticated:
|
||||
* authenticated: true
|
||||
* user:
|
||||
* id: "abc123def456"
|
||||
* name: "John Doe"
|
||||
* isAdmin: false
|
||||
* notAuthenticated:
|
||||
* authenticated: false
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET http://localhost:3001/api/auth/me \
|
||||
* -b cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/auth/me', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* if (data.authenticated) {
|
||||
* console.log('User:', data.user.name);
|
||||
* }
|
||||
*/
|
||||
router.get('/me', (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
if (!user) return res.json({ authenticated: false });
|
||||
@@ -139,8 +364,57 @@ router.get('/me', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// CSRF token refresh — lets the SPA get a new token without re-logging-in
|
||||
// (e.g. after a page reload where the JS variable was lost)
|
||||
/**
|
||||
* @openapi
|
||||
* /api/auth/csrf:
|
||||
* get:
|
||||
* tags: [Auth]
|
||||
* summary: Refresh CSRF token
|
||||
* description: |
|
||||
* Returns a fresh CSRF token and sets it as a cookie.
|
||||
*
|
||||
* **Purpose:** Lets the SPA get a new CSRF token without re-authenticating
|
||||
* (e.g., after a page reload where the JS variable containing the token was lost).
|
||||
*
|
||||
* **Authentication:** No authentication required (CSRF tokens are issued to all clients).
|
||||
*
|
||||
* **Cookie Details:**
|
||||
* - Sets `csrf_token` cookie (httpOnly=false, readable by JS)
|
||||
* - sameSite=strict, secure when TRUST_PROXY is set
|
||||
*
|
||||
* **Use Case:** Call this endpoint when your application needs a fresh CSRF token
|
||||
* for state-changing requests (POST/PUT/PATCH/DELETE).
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: CSRF token
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* csrfToken:
|
||||
* type: string
|
||||
* description: Fresh CSRF token for state-changing requests
|
||||
* example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
* example:
|
||||
* csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET http://localhost:3001/api/auth/csrf \
|
||||
* -c cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/auth/csrf', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* const csrfToken = data.csrfToken; // Use this in X-CSRF-Token header
|
||||
*/
|
||||
router.get('/csrf', (req, res) => {
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
res.cookie('csrf_token', csrfToken, {
|
||||
@@ -152,7 +426,84 @@ router.get('/csrf', (req, res) => {
|
||||
res.json({ csrfToken });
|
||||
});
|
||||
|
||||
// Logout
|
||||
/**
|
||||
* @openapi
|
||||
* /api/auth/logout:
|
||||
* post:
|
||||
* tags: [Auth]
|
||||
* summary: Logout
|
||||
* description: |
|
||||
* Clears session cookies and revokes the Emby/Jellyfin access token.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header.
|
||||
*
|
||||
* **Actions Performed:**
|
||||
* 1. Revokes the Emby/Jellyfin access token on the Emby server
|
||||
* 2. Clears the server-side token store
|
||||
* 3. Clears the `emby_user` cookie
|
||||
* 4. Clears the `csrf_token` cookie
|
||||
*
|
||||
* **Error Handling:** If Emby token revocation fails, the logout still succeeds
|
||||
* (cookies are cleared) but a warning is logged.
|
||||
*
|
||||
* **x-integration-notes:** After logout, the client must discard the CSRF token
|
||||
* and not attempt further authenticated requests until re-authenticating.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Logout successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* example:
|
||||
* success: true
|
||||
* '401':
|
||||
* description: Not authenticated (no valid session)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '403':
|
||||
* description: CSRF token missing or invalid
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* # Get CSRF token first
|
||||
* CSRF_TOKEN=$(curl -s -c cookies.txt http://localhost:3001/api/auth/csrf | jq -r .csrfToken)
|
||||
* # Logout
|
||||
* curl -X POST http://localhost:3001/api/auth/logout \
|
||||
* -H "X-CSRF-Token: $CSRF_TOKEN" \
|
||||
* -b cookies.txt \
|
||||
* -c cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const { csrfToken } = await csrfResponse.json();
|
||||
*
|
||||
* const response = await fetch('http://localhost:3001/api/auth/logout', {
|
||||
* method: 'POST',
|
||||
* headers: { 'X-CSRF-Token': csrfToken },
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* console.log(data.success); // true
|
||||
*/
|
||||
router.post('/logout', async (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
if (user) {
|
||||
|
||||
+494
-37
@@ -11,6 +11,10 @@ const sanitizeError = require('../utils/sanitizeError');
|
||||
const TagMatcher = require('../services/TagMatcher');
|
||||
const { buildUserDownloads } = require('../services/DownloadBuilder');
|
||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
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
|
||||
@@ -27,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 } },
|
||||
@@ -37,7 +42,8 @@ function readCacheSnapshot() {
|
||||
radarrHistory: { data: radarrHistoryData },
|
||||
radarrTags: { data: radarrTagsData },
|
||||
qbittorrentTorrents,
|
||||
sonarrTagsResults
|
||||
sonarrTagsResults,
|
||||
ombiRequests
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,8 +68,95 @@ function buildMetadataMaps(snapshot) {
|
||||
return { seriesMap, moviesMap, sonarrTagMap, radarrTagMap };
|
||||
}
|
||||
|
||||
// Get user downloads for authenticated user
|
||||
// DEPRECATED: Use /stream endpoint for real-time updates
|
||||
/**
|
||||
* @openapi
|
||||
* /api/dashboard/user-downloads:
|
||||
* get:
|
||||
* tags: [Dashboard]
|
||||
* summary: Get user downloads (deprecated)
|
||||
* description: |
|
||||
* **DEPRECATED:** Use GET /api/dashboard/stream for real-time updates via Server-Sent Events.
|
||||
*
|
||||
* Returns current download data for the authenticated user. This endpoint fetches
|
||||
* data from cache or triggers a fresh poll if polling is disabled and cache is empty.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie.
|
||||
*
|
||||
* **Filtering:**
|
||||
* - Non-admin users: Only see downloads tagged with their username
|
||||
* - Admin users: Can see all downloads by setting query parameter `showAll=true`
|
||||
*
|
||||
* **Data Sources:** Aggregates data from SABnzbd, qBittorrent, Transmission, rTorrent,
|
||||
* Sonarr, and Radarr, matching downloads to series/movie metadata.
|
||||
*
|
||||
* **x-integration-notes:** This endpoint returns a snapshot. For real-time updates,
|
||||
* use the SSE stream at /api/dashboard/stream instead.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: showAll
|
||||
* in: query
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: 'Admin-only: show all users'' downloads'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: User downloads
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: string
|
||||
* example: "john"
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* downloads:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/NormalizedDownload'
|
||||
* example:
|
||||
* user: "john"
|
||||
* isAdmin: false
|
||||
* downloads:
|
||||
* - id: "abc123"
|
||||
* title: "Show.Name.S01E01.1080p.WEB-DL"
|
||||
* type: "torrent"
|
||||
* client: "qbittorrent"
|
||||
* status: "Downloading"
|
||||
* progress: 45.5
|
||||
* size: 1073741824
|
||||
* downloaded: 536870912
|
||||
* '401':
|
||||
* description: Not authenticated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '500':
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET "http://localhost:3001/api/dashboard/user-downloads" \
|
||||
* -b cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/dashboard/user-downloads', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
*/
|
||||
router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
@@ -80,6 +173,11 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot);
|
||||
const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map();
|
||||
|
||||
// Get Ombi configuration
|
||||
const ombiInstances = getOmbiInstances();
|
||||
const ombiRetriever = ombiInstances.length > 0 ? arrRetrieverRegistry.getOmbiRetrievers()[0] : null;
|
||||
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
|
||||
|
||||
const userDownloads = await buildUserDownloads(snapshot, {
|
||||
username,
|
||||
usernameSanitized: user.name,
|
||||
@@ -89,7 +187,9 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -103,9 +203,91 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cover art proxy — fetches external poster images server-side so the
|
||||
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||
// Requires authentication. Only proxies http/https URLs.
|
||||
/**
|
||||
* @openapi
|
||||
* /api/dashboard/cover-art:
|
||||
* get:
|
||||
* tags: [Dashboard]
|
||||
* summary: Cover art proxy
|
||||
* description: |
|
||||
* Proxies external poster images server-side so the browser loads them from 'self'
|
||||
* and the Content Security Policy (CSP) img-src directive stays tight.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie.
|
||||
*
|
||||
* **Purpose:** Sonarr/Radarr return image URLs from external domains. To maintain
|
||||
* a strict CSP (img-src: 'self'), this endpoint fetches the image server-side and
|
||||
* serves it as if it originated from the sofarr domain.
|
||||
*
|
||||
* **Constraints:**
|
||||
* - URL must be http:// or https://
|
||||
* - Content type must be an image (image/*)
|
||||
* - Maximum image size: 5 MB
|
||||
* - Timeout: 8 seconds
|
||||
* - Browser cache: 24 hours (public, max-age=86400)
|
||||
*
|
||||
* **Error Responses:**
|
||||
* - 400: Missing URL, invalid URL, or non-image content type
|
||||
* - 502: Failed to fetch from remote server
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: url
|
||||
* in: query
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uri
|
||||
* description: External image URL to proxy
|
||||
* example: "http://sonarr:8989/media/poster.jpg"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Image data
|
||||
* content:
|
||||
* image/*:
|
||||
* headers:
|
||||
* Content-Type:
|
||||
* description: Image content type from remote server
|
||||
* schema:
|
||||
* type: string
|
||||
* Cache-Control:
|
||||
* description: Cache directive (24 hours)
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "public, max-age=86400"
|
||||
* '400':
|
||||
* description: Invalid URL or non-image
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* examples:
|
||||
* missingUrl:
|
||||
* error: "Missing url parameter"
|
||||
* invalidUrl:
|
||||
* error: "Invalid url"
|
||||
* notImage:
|
||||
* error: "Remote URL is not an image"
|
||||
* '502':
|
||||
* description: Failed to fetch from remote
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Failed to fetch cover art"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET "http://localhost:3001/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" \
|
||||
* -b cookies.txt \
|
||||
* --output poster.jpg
|
||||
* - lang: HTML
|
||||
* label: HTML img tag
|
||||
* source: |
|
||||
* <img src="/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" alt="Poster" />
|
||||
*/
|
||||
router.get('/cover-art', requireAuth, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url || typeof url !== 'string') {
|
||||
@@ -140,10 +322,123 @@ router.get('/cover-art', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// SSE stream — pushes download data to the client on every poll cycle.
|
||||
// Uses the browser's built-in EventSource API (no library required).
|
||||
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
|
||||
// No CSRF token needed — SSE is a GET request (safe method, no state change).
|
||||
/**
|
||||
* @openapi
|
||||
* /api/dashboard/stream:
|
||||
* get:
|
||||
* tags: [Dashboard]
|
||||
* summary: SSE stream for real-time updates
|
||||
* description: |
|
||||
* Server-Sent Events (SSE) stream that pushes download data to the client on every poll cycle.
|
||||
* Uses the browser's built-in EventSource API (no library required).
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie. CSRF token NOT required (GET request).
|
||||
*
|
||||
* **SSE Event Format:**
|
||||
* - Initial payload sent immediately on connection
|
||||
* - Subsequent payloads sent after each poll cycle (or webhook-triggered refresh)
|
||||
* - Each payload is a `data:` frame containing JSON with `user`, `isAdmin`, `downloads`, and `downloadClients`
|
||||
* - Heartbeat comment (`: heartbeat`) sent every 25 seconds to keep connection alive
|
||||
* - Optional `history-update` event when history is refreshed
|
||||
*
|
||||
* **Payload Structure:**
|
||||
* ```json
|
||||
* {
|
||||
* "user": "john",
|
||||
* "isAdmin": false,
|
||||
* "downloads": [...],
|
||||
* "downloadClients": [
|
||||
* { "id": "qbittorrent-main", "name": "Main qBittorrent", "type": "qbittorrent" }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Filtering:**
|
||||
* - Non-admin users: Only see downloads tagged with their username
|
||||
* - Admin users: Can see all downloads by setting query parameter `showAll=true`
|
||||
*
|
||||
* **Connection Management:**
|
||||
* - Server tracks active clients for cleanup and admin status panel
|
||||
* - On client disconnect: deregisters callback, stops heartbeat, removes from active clients
|
||||
* - Browser's EventSource API handles automatic reconnection on network interruption
|
||||
*
|
||||
* **Headers:**
|
||||
* - Content-Type: text/event-stream
|
||||
* - Cache-Control: no-cache, no-transform
|
||||
* - Connection: keep-alive
|
||||
* - X-Accel-Buffering: no (disables nginx proxy buffering)
|
||||
*
|
||||
* **x-integration-notes:** Use EventSource in browser:
|
||||
* ```javascript
|
||||
* const eventSource = new EventSource('/api/dashboard/stream', { withCredentials: true });
|
||||
* eventSource.onmessage = (event) => {
|
||||
* const data = JSON.parse(event.data);
|
||||
* console.log('Downloads:', data.downloads);
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* **x-integration-notes:** This endpoint uses Server-Sent Events (SSE) for real-time updates. No CSRF token required since it's a GET request.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: showAll
|
||||
* in: query
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: 'Admin-only: show all users'' downloads'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: SSE stream established
|
||||
* content:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Server-Sent Events stream
|
||||
* headers:
|
||||
* Content-Type:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "text/event-stream"
|
||||
* Cache-Control:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "no-cache, no-transform"
|
||||
* Connection:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "keep-alive"
|
||||
* X-Accel-Buffering:
|
||||
* schema:
|
||||
* type: string
|
||||
* example: "no"
|
||||
* '401':
|
||||
* description: Not authenticated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* x-code-samples:
|
||||
* - lang: JavaScript
|
||||
* label: Browser EventSource
|
||||
* source: |
|
||||
* const eventSource = new EventSource('/api/dashboard/stream', {
|
||||
* withCredentials: true
|
||||
* });
|
||||
* eventSource.onmessage = (event) => {
|
||||
* const data = JSON.parse(event.data);
|
||||
* console.log('User:', data.user);
|
||||
* console.log('Downloads:', data.downloads.length);
|
||||
* };
|
||||
* eventSource.addEventListener('history-update', (event) => {
|
||||
* const data = JSON.parse(event.data);
|
||||
* console.log('History updated for:', data.type);
|
||||
* });
|
||||
* - lang: curl
|
||||
* label: cURL (test SSE)
|
||||
* source: |
|
||||
* curl -N -H "Cookie: emby_user=..." http://localhost:3001/api/dashboard/stream
|
||||
*/
|
||||
router.get('/stream', requireAuth, async (req, res) => {
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
@@ -173,7 +468,12 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot);
|
||||
const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map();
|
||||
|
||||
const userDownloads = buildUserDownloads(snapshot, {
|
||||
// Get Ombi configuration
|
||||
const ombiInstances = getOmbiInstances();
|
||||
const ombiRetriever = ombiInstances.length > 0 ? arrRetrieverRegistry.getOmbiRetrievers()[0] : null;
|
||||
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
|
||||
|
||||
const userDownloads = await buildUserDownloads(snapshot, {
|
||||
username,
|
||||
usernameSanitized: user.name,
|
||||
isAdmin,
|
||||
@@ -182,7 +482,9 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
});
|
||||
|
||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||
@@ -191,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));
|
||||
}
|
||||
@@ -200,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);
|
||||
|
||||
@@ -230,39 +552,168 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/dashboard/blocklist-search
|
||||
* @openapi
|
||||
* /api/dashboard/blocklist-search:
|
||||
* post:
|
||||
* tags: [Dashboard]
|
||||
* summary: Blocklist and re-search
|
||||
* description: |
|
||||
* 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.
|
||||
*
|
||||
* Admin-only. 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.
|
||||
* 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%
|
||||
*
|
||||
* Body: {
|
||||
* arrQueueId: number — Sonarr/Radarr queue record id
|
||||
* arrType: 'sonarr'|'radarr'
|
||||
* arrInstanceUrl: string — base URL of the arr instance
|
||||
* arrInstanceKey: string — API key for the arr instance
|
||||
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
|
||||
* arrContentType: 'episode'|'movie'
|
||||
* }
|
||||
* **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header.
|
||||
*
|
||||
* **Workflow:**
|
||||
* 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
|
||||
* - Radarr: MoviesSearch with movieIds
|
||||
* 5. Invalidate poll cache so next SSE push reflects the removed item
|
||||
*
|
||||
* **Required Fields:**
|
||||
* - `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 (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: 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 a qualified user or admin
|
||||
* clicks "Blocklist + Re-search" on a stalled or failed download.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/BlocklistSearchRequest'
|
||||
* example:
|
||||
* arrQueueId: 123
|
||||
* arrType: "sonarr"
|
||||
* arrInstanceUrl: "http://sonarr:8989"
|
||||
* arrInstanceKey: "abc123def456"
|
||||
* arrContentId: 456
|
||||
* arrContentType: "episode"
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Blocklist and search successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ok:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* example:
|
||||
* ok: true
|
||||
* '400':
|
||||
* description: Missing required fields or invalid arrType
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Missing required fields"
|
||||
* '403':
|
||||
* description: Permission denied (admin or qualifying conditions required)
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Permission denied: admin or qualifying conditions required"
|
||||
* '502':
|
||||
* description: Failed to communicate with *arr instance
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Failed to blocklist and search"
|
||||
* x-code-samples:
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const { csrfToken } = await csrfResponse.json();
|
||||
*
|
||||
* const response = await fetch('http://localhost:3001/api/dashboard/blocklist-search', {
|
||||
* method: 'POST',
|
||||
* headers: {
|
||||
* 'Content-Type': 'application/json',
|
||||
* 'X-CSRF-Token': csrfToken
|
||||
* },
|
||||
* credentials: 'include',
|
||||
* body: JSON.stringify({
|
||||
* arrQueueId: 123,
|
||||
* arrType: 'sonarr',
|
||||
* arrInstanceUrl: 'http://sonarr:8989',
|
||||
* arrInstanceKey: 'abc123def456',
|
||||
* arrContentId: 456,
|
||||
* arrContentType: 'episode'
|
||||
* })
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* console.log(data.ok); // true
|
||||
*/
|
||||
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, arrContentIds, arrSeriesId, arrContentType } = req.body;
|
||||
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||
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}`, {
|
||||
@@ -273,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
// Step 2: Trigger a new automatic search
|
||||
let commandBody;
|
||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
||||
if (arrContentId) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
|
||||
} else if (arrSeriesId) {
|
||||
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
|
||||
}
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
|
||||
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||
}
|
||||
|
||||
@@ -286,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
const { pollAllServices } = require('../utils/poller');
|
||||
pollAllServices().catch(() => {});
|
||||
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||
const {
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
} = require('../utils/logCapture');
|
||||
|
||||
// Public status check (no auth, no 403 block, returns standard config state)
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||
});
|
||||
|
||||
// Global toggle check
|
||||
router.use((req, res, next) => {
|
||||
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Enforce subnet and authentication validations on all debug routes
|
||||
router.use(logStreamAuth);
|
||||
|
||||
/**
|
||||
* GET /api/debug/server-logs
|
||||
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||
*/
|
||||
router.get('/server-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical server logs buffer first
|
||||
for (const line of logBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('server-log', sendLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('server-log', sendLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/client-logs
|
||||
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||
*/
|
||||
router.get('/client-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical client logs buffer first
|
||||
for (const line of clientLogBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendClientLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('client-log', sendClientLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('client-log', sendClientLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/debug/client-logs
|
||||
* Receives batches of frontend console logs to store in buffer and emit.
|
||||
*/
|
||||
router.post('/client-logs', (req, res) => {
|
||||
const logs = req.body;
|
||||
if (!Array.isArray(logs)) {
|
||||
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||
}
|
||||
|
||||
try {
|
||||
ingestClientLogs(logs);
|
||||
return res.status(200).json({ success: true, count: logs.length });
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+109
-15
@@ -5,9 +5,26 @@ const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/emby/sessions:
|
||||
* get:
|
||||
* tags: [Emby]
|
||||
* summary: Get active Emby sessions
|
||||
* description: Proxy to Emby's sessions endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Sessions data from Emby
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
*/
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get active sessions
|
||||
// GET /api/emby/sessions - list active Emby sessions
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
@@ -19,19 +36,24 @@ router.get('/sessions', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
/**
|
||||
* @openapi
|
||||
* /api/emby/users:
|
||||
* get:
|
||||
* tags: [Emby]
|
||||
* summary: Get all Emby users
|
||||
* description: Proxy to Emby's users list endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Users list from Emby
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
*/
|
||||
// GET /api/emby/users - list all users
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
@@ -43,7 +65,79 @@ router.get('/users', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user by session ID
|
||||
/**
|
||||
* @openapi
|
||||
* /api/emby/users/{id}:
|
||||
* get:
|
||||
* tags: [Emby]
|
||||
* summary: Get user by ID
|
||||
* description: Get details for a specific Emby user by ID. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Emby user ID
|
||||
* responses:
|
||||
* '200':
|
||||
* description: User data from Emby
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '500':
|
||||
* description: Failed to fetch user from Emby
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// GET /api/emby/users/:id - get individual user by ID
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/emby/session/{sessionId}/user:
|
||||
* get:
|
||||
* tags: [Emby]
|
||||
* summary: Get user from session
|
||||
* description: Get user details for a specific session ID. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: sessionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Emby session ID
|
||||
* responses:
|
||||
* '200':
|
||||
* description: User data from Emby
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '404':
|
||||
* description: Session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// GET /api/emby/session/:sessionId/user - get user for a specific session
|
||||
router.get('/session/:sessionId/user', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
|
||||
+151
-157
@@ -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 } = require('../utils/config');
|
||||
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,49 +79,139 @@ 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/recent
|
||||
* @openapi
|
||||
* /api/history/recent:
|
||||
* get:
|
||||
* tags: [History]
|
||||
* summary: Get recent history
|
||||
* description: |
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the authenticated user,
|
||||
* filtered to the last N days (default 7, max 90).
|
||||
*
|
||||
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||
* (default 7, overridable via env or ?days= query param).
|
||||
* **Authentication:** Requires valid `emby_user` cookie.
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* user: string,
|
||||
* isAdmin: boolean,
|
||||
* days: number,
|
||||
* history: HistoryItem[]
|
||||
* }
|
||||
* **Filtering:**
|
||||
* - Non-admin users: Only see history items tagged with their username
|
||||
* - Admin users: Can see all history by setting query parameter `showAll=true`
|
||||
* - Date range: Configurable via `?days=` query parameter (default: 7, max: 90)
|
||||
*
|
||||
* HistoryItem shape:
|
||||
* {
|
||||
* type: 'series'|'movie',
|
||||
* outcome: 'imported'|'failed',
|
||||
* title: string, // sourceTitle from arr record
|
||||
* seriesName?: string, // series.title (Sonarr)
|
||||
* movieName?: string, // movie.title (Radarr)
|
||||
* coverArt: string|null,
|
||||
* completedAt: string, // ISO date string from arr record
|
||||
* quality: string|null,
|
||||
* instanceName: string, // arr instance name
|
||||
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||
* allTags: string[],
|
||||
* matchedUserTag: string|null,
|
||||
* // admin-only:
|
||||
* arrRecordId?: number,
|
||||
* failureMessage?: string,
|
||||
* }
|
||||
* **Deduplication Rules:**
|
||||
* For each unique content item (episode or movie), only the most recent record is shown:
|
||||
* - If the most recent event is "imported" → show it; suppress older failures
|
||||
* - If the most recent event is "failed" and the item has a file on disk → show with `availableForUpgrade=true`
|
||||
* - If the most recent event is "failed" and no file exists → show normally
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - Sonarr: DownloadFolderImported, ImportFailed → included
|
||||
* - Radarr: DownloadFolderImported, ImportFailed → included
|
||||
* - Other event types (Rename, Health, etc.) → excluded
|
||||
*
|
||||
* **Response Structure:**
|
||||
* - `type`: "series" or "movie"
|
||||
* - `outcome`: "imported" or "failed"
|
||||
* - `title`: Source title from *arr record
|
||||
* - `seriesName`/`movieName`: Friendly media title
|
||||
* - `coverArt`: Poster URL
|
||||
* - `completedAt`: ISO 8601 timestamp
|
||||
* - `quality`: Quality string (e.g., "HDTV-1080p")
|
||||
* - `instanceName`: *arr instance name
|
||||
* - `arrLink`: Link to item in *arr UI
|
||||
* - `allTags`: All tags on the series/movie
|
||||
* - `matchedUserTag`: Tag matching the requesting user
|
||||
* - `availableForUpgrade`: True if failed but content is on disk (admin-only)
|
||||
* - `failureMessage`: Failure details (admin-only)
|
||||
*
|
||||
* **x-integration-notes:** Used by the history tab to show recently completed downloads.
|
||||
* Episodes are gathered from all history records sharing the same source title.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: days
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 90
|
||||
* default: 7
|
||||
* description: Number of days to look back (max 90)
|
||||
* example: 7
|
||||
* - name: showAll
|
||||
* in: query
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: 'Admin-only: show all users'' history'
|
||||
* responses:
|
||||
* '200':
|
||||
* description: History items
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: string
|
||||
* example: "john"
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* days:
|
||||
* type: integer
|
||||
* example: 7
|
||||
* history:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/HistoryItem'
|
||||
* example:
|
||||
* user: "john"
|
||||
* isAdmin: false
|
||||
* days: 7
|
||||
* history:
|
||||
* - type: "series"
|
||||
* outcome: "imported"
|
||||
* title: "Show.Name.S01E01.1080p.WEB-DL"
|
||||
* seriesName: "Show Name"
|
||||
* episodes:
|
||||
* - season: 1
|
||||
* episode: 1
|
||||
* title: "Pilot"
|
||||
* coverArt: "http://sonarr:8989/media/poster.jpg"
|
||||
* completedAt: "2026-05-21T10:00:00.000Z"
|
||||
* quality: "HDTV-1080p"
|
||||
* instanceName: "Main Sonarr"
|
||||
* arrLink: "http://sonarr:8989/series/show-slug"
|
||||
* allTags: ["user-john"]
|
||||
* matchedUserTag: "user-john"
|
||||
* '401':
|
||||
* description: Not authenticated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '500':
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET "http://localhost:3001/api/history/recent?days=7" \
|
||||
* -b cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/history/recent?days=7', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* console.log('History items:', data.history.length);
|
||||
*/
|
||||
router.get('/recent', requireAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -245,10 +230,13 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
const ombiInstances = getOmbiInstances();
|
||||
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
|
||||
|
||||
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,
|
||||
@@ -269,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;
|
||||
@@ -285,20 +273,23 @@ 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),
|
||||
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
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
item.arrType = 'sonarr';
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
@@ -319,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;
|
||||
@@ -334,19 +325,22 @@ 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),
|
||||
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
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
item.arrType = 'radarr';
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
// 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(true);
|
||||
|
||||
// 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');
|
||||
|
||||
// Get existing settings to retrieve the database ID
|
||||
const currentRes = await axios.get(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
headers: {
|
||||
'ApiKey': ombiInst.apiKey
|
||||
}
|
||||
}
|
||||
).catch(err => {
|
||||
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
const currentConfig = currentRes.data || {};
|
||||
const settingsId = currentConfig.id || 0;
|
||||
|
||||
const response = await axios.post(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
id: settingsId,
|
||||
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;
|
||||
+132
-18
@@ -15,13 +15,41 @@ function getFirstRadarrInstance() {
|
||||
return instances[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/radarr/queue:
|
||||
* get:
|
||||
* tags: [Radarr]
|
||||
* summary: Get Radarr queue
|
||||
* description: Proxy to Radarr's queue endpoint. Requires authentication and CSRF token.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Queue data from Radarr
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '500':
|
||||
* description: Proxy error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
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) {
|
||||
@@ -29,11 +57,39 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/radarr/history:
|
||||
* get:
|
||||
* tags: [Radarr]
|
||||
* summary: Get Radarr history
|
||||
* description: Proxy to Radarr's history endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: pageSize
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Number of records per page
|
||||
* responses:
|
||||
* '200':
|
||||
* description: History data from Radarr
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
*/
|
||||
// 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);
|
||||
@@ -44,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) {
|
||||
@@ -56,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) {
|
||||
@@ -66,6 +130,36 @@ router.get('/movies', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/radarr/notifications/sofarr-webhook:
|
||||
* post:
|
||||
* tags: [Radarr]
|
||||
* summary: Configure Sofarr webhook
|
||||
* description: One-click setup for Sofarr webhook notification in Radarr. Requires authentication and CSRF token.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Configured notification
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '400':
|
||||
* description: Missing configuration
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '503':
|
||||
* description: Radarr not configured
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/radarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
@@ -86,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) {
|
||||
@@ -98,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) {
|
||||
@@ -110,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) {
|
||||
@@ -122,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) {
|
||||
@@ -155,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,16 +4,53 @@ 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
|
||||
* /api/sabnzbd/queue:
|
||||
* get:
|
||||
* tags: [SABnzbd]
|
||||
* summary: Get SABnzbd queue
|
||||
* description: Proxy to SABnzbd's queue endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Queue data from SABnzbd
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '500':
|
||||
* description: Proxy error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get current queue
|
||||
// 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'
|
||||
}
|
||||
});
|
||||
@@ -23,13 +60,41 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
/**
|
||||
* @openapi
|
||||
* /api/sabnzbd/history:
|
||||
* get:
|
||||
* tags: [SABnzbd]
|
||||
* summary: Get SABnzbd history
|
||||
* description: Proxy to SABnzbd's history endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: limit
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Number of history records to return
|
||||
* responses:
|
||||
* '200':
|
||||
* description: History data from SABnzbd
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
*/
|
||||
// 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
|
||||
}
|
||||
|
||||
+132
-18
@@ -15,13 +15,41 @@ function getFirstSonarrInstance() {
|
||||
return instances[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/sonarr/queue:
|
||||
* get:
|
||||
* tags: [Sonarr]
|
||||
* summary: Get Sonarr queue
|
||||
* description: Proxy to Sonarr's queue endpoint. Requires authentication and CSRF token.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Queue data from Sonarr
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '500':
|
||||
* description: Proxy error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
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) {
|
||||
@@ -29,11 +57,39 @@ router.get('/queue', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/sonarr/history:
|
||||
* get:
|
||||
* tags: [Sonarr]
|
||||
* summary: Get Sonarr history
|
||||
* description: Proxy to Sonarr's history endpoint. Requires authentication.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* parameters:
|
||||
* - name: pageSize
|
||||
* in: query
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* description: Number of records per page
|
||||
* responses:
|
||||
* '200':
|
||||
* description: History data from Sonarr
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
*/
|
||||
// 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);
|
||||
@@ -44,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) {
|
||||
@@ -56,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) {
|
||||
@@ -66,6 +130,36 @@ router.get('/series', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/sonarr/notifications/sofarr-webhook:
|
||||
* post:
|
||||
* tags: [Sonarr]
|
||||
* summary: Configure Sofarr webhook
|
||||
* description: One-click setup for Sofarr webhook notification in Sonarr. Requires authentication and CSRF token.
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Configured notification
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* '400':
|
||||
* description: Missing configuration
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '503':
|
||||
* description: Sonarr not configured
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/sonarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
@@ -86,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) {
|
||||
@@ -98,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) {
|
||||
@@ -110,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) {
|
||||
@@ -122,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) {
|
||||
@@ -155,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) {
|
||||
|
||||
+109
-5
@@ -4,11 +4,107 @@ const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
|
||||
// Admin-only status page with cache stats
|
||||
/**
|
||||
* @openapi
|
||||
* /api/status/status:
|
||||
* get:
|
||||
* tags: [Status]
|
||||
* summary: Get server status (admin-only)
|
||||
* description: |
|
||||
* Admin-only endpoint returning server metrics, cache statistics, polling information,
|
||||
* and webhook metrics. Used by the admin status panel to monitor sofarr health.
|
||||
*
|
||||
* **Authentication:** Requires valid `emby_user` cookie (admin only).
|
||||
*
|
||||
* **Response Structure:**
|
||||
* - `server`: Uptime, Node version, memory usage
|
||||
* - `polling`: Polling enabled status, interval, last poll timings
|
||||
* - `cache`: Cache statistics (item count, sizes, TTLs)
|
||||
* - `webhooks`: Webhook configuration and metrics for Sonarr/Radarr
|
||||
*
|
||||
* **Webhook Metrics:**
|
||||
* - `configured`: Whether webhook is configured in Sonarr/Radarr
|
||||
* - `eventsReceived`: Total webhook events received
|
||||
* - `lastWebhookTimestamp`: Last webhook event time
|
||||
* - `pollsSkipped`: Number of poll cycles skipped due to recent webhook activity
|
||||
*
|
||||
* **x-integration-notes:** This endpoint is used by the admin status panel to display:
|
||||
* - Server health and resource usage
|
||||
* - Polling performance and timing
|
||||
* - Cache hit rates and sizes
|
||||
* - Webhook activity and smart polling effectiveness
|
||||
* security:
|
||||
* - CookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Status data
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/StatusResponse'
|
||||
* example:
|
||||
* server:
|
||||
* uptimeSeconds: 3600
|
||||
* nodeVersion: "v22.0.0"
|
||||
* memoryUsageMB: 128.5
|
||||
* heapUsedMB: 64.2
|
||||
* heapTotalMB: 128.0
|
||||
* polling:
|
||||
* enabled: true
|
||||
* intervalMs: 5000
|
||||
* lastPoll:
|
||||
* sabnzbdQueue: 150
|
||||
* sonarrQueue: 200
|
||||
* cache:
|
||||
* "poll:sab-queue":
|
||||
* size: 2456
|
||||
* items: 1
|
||||
* ttlRemaining: 12000
|
||||
* webhooks:
|
||||
* sonarr:
|
||||
* configured: true
|
||||
* eventsReceived: 42
|
||||
* lastWebhookTimestamp: "2026-05-21T10:00:00.000Z"
|
||||
* pollsSkipped: 15
|
||||
* radarr:
|
||||
* configured: true
|
||||
* eventsReceived: 38
|
||||
* lastWebhookTimestamp: "2026-05-21T09:55:00.000Z"
|
||||
* pollsSkipped: 12
|
||||
* '403':
|
||||
* description: Admin access required
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Admin access required"
|
||||
* '500':
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: |
|
||||
* curl -X GET http://localhost:3001/api/status/status \
|
||||
* -b cookies.txt
|
||||
* - lang: JavaScript
|
||||
* label: JavaScript (fetch)
|
||||
* source: |
|
||||
* const response = await fetch('http://localhost:3001/api/status/status', {
|
||||
* method: 'GET',
|
||||
* credentials: 'include'
|
||||
* });
|
||||
* const data = await response.json();
|
||||
* console.log('Uptime:', data.server.uptimeSeconds);
|
||||
*/
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
@@ -25,6 +121,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
// Check webhook configuration for each service
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||
@@ -32,15 +129,21 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||
: false;
|
||||
const ombiWebhookConfigured = ombiInstances.length > 0
|
||||
? await checkOmbiWebhookConfigured(ombiInstances[0])
|
||||
: false;
|
||||
|
||||
// Find Sonarr and Radarr metrics from instances
|
||||
// Find Sonarr, Radarr, and Ombi metrics from instances
|
||||
const sonarrMetrics = {};
|
||||
const radarrMetrics = {};
|
||||
const ombiMetrics = {};
|
||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||
if (url.includes('sonarr')) {
|
||||
sonarrMetrics[url] = metrics;
|
||||
} else if (url.includes('radarr')) {
|
||||
radarrMetrics[url] = metrics;
|
||||
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
|
||||
ombiMetrics[url] = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +163,8 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
cache: cacheStats,
|
||||
webhooks: {
|
||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
+463
-17
@@ -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
|
||||
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||
]);
|
||||
|
||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||
@@ -73,6 +133,16 @@ const HISTORY_EVENTS = new Set([
|
||||
'EpisodeFileRenamedBySeries'
|
||||
]);
|
||||
|
||||
// Ombi event types — all Ombi events refresh the requests cache
|
||||
const OMBI_EVENTS = new Set([
|
||||
'NewRequest',
|
||||
'RequestAvailable',
|
||||
'RequestApproved',
|
||||
'RequestDeclined',
|
||||
'RequestPending',
|
||||
'RequestProcessing'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
||||
* @param {Object} req - Express request object
|
||||
@@ -107,19 +177,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 +255,16 @@ 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) {
|
||||
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
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.
|
||||
@@ -219,12 +300,107 @@ function validatePayload(body) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhook/sonarr
|
||||
* Receives webhook events from Sonarr instances.
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
* @openapi
|
||||
* /api/webhook/sonarr:
|
||||
* post:
|
||||
* tags: [Webhook]
|
||||
* summary: Sonarr webhook receiver
|
||||
* description: |
|
||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Sonarr, 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 eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
|
||||
* Refreshes `poll:sonarr-queue` cache
|
||||
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, EpisodeFileRenamed, etc.):
|
||||
* Refreshes `poll:sonarr-history` cache
|
||||
* - Informational events (Test, Rename, Health, etc.):
|
||||
* Logged but no cache refresh
|
||||
*
|
||||
* **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 Sonarr, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Sonarr webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/sonarr`
|
||||
* - Method: POST
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/WebhookPayload'
|
||||
* example:
|
||||
* eventType: "Grab"
|
||||
* instanceName: "Main Sonarr"
|
||||
* date: "2026-05-21T10:00:00.000Z"
|
||||
* 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 eventType: InvalidEvent"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL (from Sonarr)
|
||||
* source: |
|
||||
* curl -X POST http://sofarr:3001/api/webhook/sonarr \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
||||
* -d '{"eventType":"Grab","instanceName":"Main Sonarr","date":"2026-05-21T10:00:00.000Z"}'
|
||||
*/
|
||||
router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
@@ -271,12 +447,107 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/webhook/radarr
|
||||
* Receives webhook events from Radarr instances.
|
||||
* Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200.
|
||||
* @openapi
|
||||
* /api/webhook/radarr:
|
||||
* post:
|
||||
* tags: [Webhook]
|
||||
* summary: Radarr webhook receiver
|
||||
* description: |
|
||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates.
|
||||
* Phase 6: rate limiting, input validation, replay protection.
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Radarr, 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 eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
*
|
||||
* **Event Classification:**
|
||||
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
|
||||
* Refreshes `poll:radarr-queue` cache
|
||||
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, MovieFileRenamed, etc.):
|
||||
* Refreshes `poll:radarr-history` cache
|
||||
* - Informational events (Test, Rename, Health, etc.):
|
||||
* Logged but no cache refresh
|
||||
*
|
||||
* **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 Radarr, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Radarr webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/radarr`
|
||||
* - Method: POST
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/WebhookPayload'
|
||||
* example:
|
||||
* eventType: "Grab"
|
||||
* instanceName: "Main Radarr"
|
||||
* date: "2026-05-21T10:00:00.000Z"
|
||||
* 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 eventType: InvalidEvent"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL (from Radarr)
|
||||
* source: |
|
||||
* curl -X POST http://sofarr:3001/api/webhook/radarr \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
||||
* -d '{"eventType":"Grab","instanceName":"Main Radarr","date":"2026-05-21T10:00:00.000Z"}'
|
||||
*/
|
||||
router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
if (!validateWebhookSecret(req)) {
|
||||
@@ -322,4 +593,179 @@ 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. Support PascalCase for .NET apps
|
||||
const notificationType = req.body.notificationType || req.body.NotificationType;
|
||||
const requestId = req.body.requestId || req.body.RequestId;
|
||||
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
||||
|
||||
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
||||
|
||||
// Extract username from requestedUser (handles both object and string formats)
|
||||
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 || 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;
|
||||
|
||||
@@ -45,6 +45,21 @@ function getRadarrLink(movie) {
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Helper to build Ombi details link using TMDB ID from *arr media object
|
||||
// Movies: {ombiBaseUrl}/details/movie/{tmdbId}
|
||||
// TV: {ombiBaseUrl}/details/tv/{tmdbId}
|
||||
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}`;
|
||||
} else if (type === 'movie') {
|
||||
return `${ombiBaseUrl}/details/movie/${tmdbId}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if a download can be blocklisted by the current user
|
||||
// Admins: always true (they have arrQueueId)
|
||||
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
||||
@@ -101,6 +116,7 @@ module.exports = {
|
||||
getImportIssues,
|
||||
getSonarrLink,
|
||||
getRadarrLink,
|
||||
getOmbiDetailsLink,
|
||||
canBlocklist,
|
||||
extractEpisode,
|
||||
gatherEpisodes
|
||||
|
||||
@@ -22,9 +22,11 @@ const DownloadMatcher = require('./DownloadMatcher');
|
||||
* @param {Map} options.sonarrTagMap - Map of Sonarr tag IDs to labels
|
||||
* @param {Map} options.radarrTagMap - Map of Radarr tag IDs to labels
|
||||
* @param {Map} options.embyUserMap - Map of Emby users for admin view
|
||||
* @param {OmbiRetriever} options.ombiRetriever - Ombi data retriever instance (optional)
|
||||
* @param {string} options.ombiBaseUrl - Ombi base URL for link generation (optional)
|
||||
* @returns {Array} Array of download objects for the user
|
||||
*/
|
||||
function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }) {
|
||||
async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap, ombiRetriever, ombiBaseUrl }) {
|
||||
// Input validation
|
||||
if (!cacheSnapshot || typeof cacheSnapshot !== 'object') {
|
||||
console.error('[DownloadBuilder] Invalid cacheSnapshot provided');
|
||||
@@ -62,7 +64,9 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
embyUserMap: embyUserMap || new Map(),
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
};
|
||||
|
||||
// Match all download sources
|
||||
@@ -70,7 +74,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
const seenDownloadKeys = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
for (const dl of sabMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
@@ -81,7 +85,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
}
|
||||
|
||||
if (sabnzbdHistory.data?.history?.slots) {
|
||||
const sabHistoryMatches = DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
const sabHistoryMatches = await DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
for (const dl of sabHistoryMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
@@ -91,7 +95,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
|
||||
@@ -44,6 +44,22 @@ function buildMoviesMapFromRecords(queueRecords, historyRecords) {
|
||||
return moviesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an Ombi details link to a download object using the TMDB ID from the *arr media object.
|
||||
* No Ombi API call is required — the link is built directly from the TMDB ID.
|
||||
* @param {Object} downloadObj - Download object to enhance
|
||||
* @param {Object} seriesOrMovie - Series or movie object from Sonarr/Radarr
|
||||
* @param {Object} context - Context containing ombiBaseUrl
|
||||
*/
|
||||
function addOmbiMatching(downloadObj, seriesOrMovie, context) {
|
||||
const { ombiBaseUrl } = context;
|
||||
const link = DownloadAssembler.getOmbiDetailsLink(seriesOrMovie, downloadObj.type, ombiBaseUrl);
|
||||
if (link) {
|
||||
downloadObj.ombiLink = link;
|
||||
downloadObj.ombiTooltip = 'View in Ombi';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the status and speed for a SABnzbd slot based on queue state.
|
||||
* @param {Object} slot - SABnzbd queue slot
|
||||
@@ -68,7 +84,7 @@ function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) {
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
function matchSabSlots(slots, context) {
|
||||
async function matchSabSlots(slots, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
@@ -84,7 +100,9 @@ function matchSabSlots(slots, context) {
|
||||
embyUserMap,
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -186,18 +204,22 @@ 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.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || 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);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -238,18 +260,20 @@ 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);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +288,7 @@ function matchSabSlots(slots, context) {
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
function matchSabHistory(slots, context) {
|
||||
async function matchSabHistory(slots, context) {
|
||||
const {
|
||||
sonarrHistoryRecords,
|
||||
radarrHistoryRecords,
|
||||
@@ -275,7 +299,9 @@ function matchSabHistory(slots, context) {
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -317,6 +343,7 @@ function matchSabHistory(slots, context) {
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +382,7 @@ function matchSabHistory(slots, context) {
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -369,7 +397,7 @@ function matchSabHistory(slots, context) {
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of matched download objects
|
||||
*/
|
||||
function matchTorrents(torrents, context) {
|
||||
async function matchTorrents(torrents, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
@@ -382,7 +410,9 @@ function matchTorrents(torrents, context) {
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -418,18 +448,22 @@ 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.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || 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);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
@@ -462,18 +496,20 @@ 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);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
@@ -506,6 +542,7 @@ function matchTorrents(torrents, context) {
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
@@ -541,6 +578,7 @@ function matchTorrents(torrents, context) {
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
}
|
||||
@@ -555,6 +593,7 @@ module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
getSlotStatusAndSpeed,
|
||||
addOmbiMatching,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
|
||||
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||
* @param {Object} instance - The Ombi instance config
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkOmbiWebhookConfigured(instance) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||
headers: { 'ApiKey': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
return !!(response.data && response.data.enabled);
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebhookConfigured,
|
||||
checkOmbiWebhookConfigured,
|
||||
aggregateMetrics
|
||||
};
|
||||
|
||||
@@ -3,17 +3,22 @@ const { logToFile } = require('./logger');
|
||||
const cache = require('./cache');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
|
||||
const TagMatcher = require('../services/TagMatcher');
|
||||
|
||||
// Import retriever classes
|
||||
const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever');
|
||||
const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever');
|
||||
const OmbiRetriever = require('../clients/OmbiRetriever');
|
||||
|
||||
// Retriever type mapping
|
||||
const retrieverClasses = {
|
||||
sonarr: PollingSonarrRetriever,
|
||||
radarr: PollingRadarrRetriever
|
||||
radarr: PollingRadarrRetriever,
|
||||
ombi: OmbiRetriever
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,11 +41,13 @@ const arrRetrieverRegistry = {
|
||||
// Get all instance configurations
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
// Create retriever instances
|
||||
const instanceConfigs = [
|
||||
...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })),
|
||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' }))
|
||||
...radarrInstances.map(inst => ({ ...inst, type: 'radarr' })),
|
||||
...ombiInstances.map(inst => ({ ...inst, type: 'ombi' }))
|
||||
];
|
||||
|
||||
for (const config of instanceConfigs) {
|
||||
@@ -303,30 +310,78 @@ const arrRetrieverRegistry = {
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => result.value)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Ombi retrievers
|
||||
* @returns {Array<OmbiRetriever>} Array of Ombi retriever instances
|
||||
*/
|
||||
getOmbiRetrievers() {
|
||||
return this.getRetrieversByType('ombi');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all Ombi requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||
*/
|
||||
async getOmbiRequests(force = false) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return { movie: [], tv: [] };
|
||||
}
|
||||
|
||||
// Use the first Ombi retriever (single instance expected)
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
const movieRequests = await retriever.getMovieRequests(force);
|
||||
const tvRequests = await retriever.getTvRequests(false);
|
||||
return { movie: movieRequests, tv: tvRequests };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||
return { movie: [], tv: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Ombi requests grouped by type
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||
*/
|
||||
async getOmbiRequestsByType(force = false) {
|
||||
return await this.getOmbiRequests(force);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find Ombi request by external IDs
|
||||
* @param {string} type - 'movie' or 'tv'
|
||||
* @param {Object} externalIds - External IDs to search with
|
||||
* @param {string} externalIds.tmdbId - TheMovieDB ID
|
||||
* @param {string} externalIds.tvdbId - TheTVDB ID (for TV)
|
||||
* @param {string} externalIds.imdbId - IMDB ID (for movies)
|
||||
* @returns {Promise<Object|null>} Ombi request object or null if not found
|
||||
*/
|
||||
async findOmbiRequest(type, externalIds) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
if (type === 'movie') {
|
||||
return await retriever.findMovieRequest(externalIds.tmdbId, externalIds.imdbId);
|
||||
} else if (type === 'tv') {
|
||||
return await retriever.findTvRequest(externalIds.tvdbId, externalIds.tmdbId);
|
||||
}
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error finding Ombi request: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||
*/
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tag matches the username: exact match first, then sanitized match
|
||||
*/
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
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.
|
||||
@@ -392,7 +447,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
|
||||
|
||||
@@ -84,6 +84,14 @@ function getRadarrInstances() {
|
||||
);
|
||||
}
|
||||
|
||||
function getOmbiInstances() {
|
||||
return parseInstances(
|
||||
process.env.OMBI_INSTANCES,
|
||||
process.env.OMBI_URL,
|
||||
process.env.OMBI_API_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getQbittorrentInstances() {
|
||||
return parseInstances(
|
||||
process.env.QBITTORRENT_INSTANCES,
|
||||
@@ -126,6 +134,7 @@ module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances,
|
||||
getOmbiInstances,
|
||||
getQbittorrentInstances,
|
||||
getTransmissionInstances,
|
||||
getRtorrentInstances,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class LogEmitter extends EventEmitter {}
|
||||
const logEmitter = new LogEmitter();
|
||||
|
||||
const logBuffer = [];
|
||||
const clientLogBuffer = [];
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
|
||||
// ANSI escape code regular expression for stripping terminal colors
|
||||
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsi(str) {
|
||||
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
|
||||
}
|
||||
|
||||
// Keep track of original stdout/stderr write functions
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
// Buffer to accumulate partial lines from stdout and stderr
|
||||
let stdoutLineBuffer = '';
|
||||
let stderrLineBuffer = '';
|
||||
|
||||
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
|
||||
let str = '';
|
||||
if (Buffer.isBuffer(data)) {
|
||||
str = data.toString(encoding || 'utf8');
|
||||
} else if (typeof data === 'string') {
|
||||
str = data;
|
||||
}
|
||||
|
||||
// Delegate writing to the original stream first
|
||||
callback.call(this, data, encoding);
|
||||
|
||||
// Append new data to the accumulator
|
||||
const accumulated = lineAccumulator.buffer + str;
|
||||
const lines = accumulated.split(/\r?\n/);
|
||||
|
||||
// The last element is either empty (if str ended with \n) or a partial line
|
||||
lineAccumulator.buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
const cleanLine = stripAnsi(line);
|
||||
if (!cleanLine) continue;
|
||||
|
||||
// Prepend timestamp if not present (format: [ISO] Message)
|
||||
const timestampedLine = cleanLine.startsWith('[')
|
||||
? cleanLine
|
||||
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
|
||||
|
||||
logBuffer.push(timestampedLine);
|
||||
if (logBuffer.length > MAX_BUFFER_SIZE) {
|
||||
logBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('server-log', timestampedLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulator objects to allow updating string buffers by reference
|
||||
const stdoutAccumulator = { buffer: '' };
|
||||
const stderrAccumulator = { buffer: '' };
|
||||
|
||||
let isHooked = false;
|
||||
|
||||
function init() {
|
||||
if (isHooked) return;
|
||||
|
||||
// Intercept stdout
|
||||
process.stdout.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stdout,
|
||||
data,
|
||||
encoding,
|
||||
originalStdoutWrite,
|
||||
'stdout',
|
||||
stdoutAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Intercept stderr
|
||||
process.stderr.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stderr,
|
||||
data,
|
||||
encoding,
|
||||
originalStderrWrite,
|
||||
'stderr',
|
||||
stderrAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
isHooked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests a list of client-side logs into the rolling clientLogBuffer.
|
||||
* Each client log is expected to have structure: { timestamp, level, message }
|
||||
*/
|
||||
function ingestClientLogs(logs) {
|
||||
if (!Array.isArray(logs)) return;
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = log.timestamp || new Date().toISOString();
|
||||
const level = (log.level || 'info').toUpperCase();
|
||||
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
|
||||
|
||||
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
|
||||
clientLogBuffer.push(formattedLog);
|
||||
|
||||
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
|
||||
clientLogBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('client-log', formattedLog);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
};
|
||||
+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,46 @@
|
||||
// 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 '';
|
||||
|
||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
||||
|
||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||
if (requestedUser && typeof requestedUser === 'object') {
|
||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||
return requestedUser.alias || requestedUser.Alias ||
|
||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
||||
requestedUser.userName || requestedUser.UserName ||
|
||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
||||
request.requestedByAlias || request.RequestedByAlias || '';
|
||||
}
|
||||
// Handle string format (fallback for compatibility)
|
||||
return requestedUser || request.requestedByAlias || 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
|
||||
};
|
||||
+22
-3
@@ -5,8 +5,10 @@ const { initializeClients, getAllDownloads, getDownloadsByClientType } = require
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||
@@ -88,13 +90,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 +105,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 +137,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 +148,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 +291,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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/utils/clientLogCapture.js
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { initClientLogCapture } from '../../../client/src/utils/clientLogCapture.js';
|
||||
|
||||
describe('clientLogCapture', () => {
|
||||
let fetchMock;
|
||||
let originalConsoleLog;
|
||||
let originalConsoleWarn;
|
||||
let originalConsoleError;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Preserve original console methods
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleWarn = console.warn;
|
||||
originalConsoleError = console.error;
|
||||
|
||||
// Reset console methods to standard ones
|
||||
console.log = vi.fn();
|
||||
console.warn = vi.fn();
|
||||
console.error = vi.fn();
|
||||
|
||||
// Mock window fetch
|
||||
fetchMock = vi.fn();
|
||||
global.window.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Restore original console methods
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('exits early and does not intercept console if status returns disabled', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: false })
|
||||
});
|
||||
|
||||
await initClientLogCapture();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||
|
||||
// Console calls should NOT be queued or overridden (i.e. they should just run vi.fn mocks)
|
||||
console.log('Test message');
|
||||
expect(console.log).toHaveBeenCalledWith('Test message');
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/debug/client-logs', expect.any(Object));
|
||||
});
|
||||
|
||||
it('hooks console and flushes logs periodically when status returns enabled', async () => {
|
||||
fetchMock.mockImplementation((url, options) => {
|
||||
if (url === '/api/debug/status') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: true })
|
||||
});
|
||||
}
|
||||
if (url === '/api/debug/client-logs') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await initClientLogCapture();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||
|
||||
// Trigger console logs
|
||||
console.log('Booting app', { config: 'loaded' });
|
||||
console.warn('Deprecated api call');
|
||||
console.error('Failed request', new Error('timeout'));
|
||||
|
||||
// Move timers forward to trigger flush interval (2000ms)
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/client-logs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}));
|
||||
|
||||
const lastCall = fetchMock.mock.calls.find(call => call[0] === '/api/debug/client-logs');
|
||||
expect(lastCall).toBeDefined();
|
||||
|
||||
const loggedEntries = JSON.parse(lastCall[1].body);
|
||||
expect(loggedEntries).toHaveLength(4); // Includes the interceptor boot message + 3 logs
|
||||
|
||||
expect(loggedEntries[1].level).toBe('info');
|
||||
expect(loggedEntries[1].message).toContain('Booting app {"config":"loaded"}');
|
||||
|
||||
expect(loggedEntries[2].level).toBe('warn');
|
||||
expect(loggedEntries[2].message).toContain('Deprecated api call');
|
||||
|
||||
expect(loggedEntries[3].level).toBe('error');
|
||||
expect(loggedEntries[3].message).toContain('Failed request');
|
||||
});
|
||||
});
|
||||
@@ -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,164 @@ 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();
|
||||
});
|
||||
|
||||
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
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' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
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' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
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,213 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
describe('Debug Logs API Integration', () => {
|
||||
beforeAll(() => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret-xyz';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
delete process.env.ENABLE_LOG_STREAM;
|
||||
delete process.env.LOG_ALLOW_SUBNETS;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('GET /api/debug/status', () => {
|
||||
it('returns enabled: false when ENABLE_LOG_STREAM is not true', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns enabled: true when ENABLE_LOG_STREAM is true', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global toggle checking', () => {
|
||||
it('returns 403 Forbidden on server logs GET when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/server-logs');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden on client logs GET when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/client-logs');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden on client logs POST when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).post('/api/debug/client-logs').send([]);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subnet CIDR validation', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
process.env.LOG_ALLOW_SUBNETS = '127.0.0.1/32,192.168.1.0/24';
|
||||
process.env.TRUST_PROXY = '1';
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden if client IP is not in subnet allowlist', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.set('X-Forwarded-For', '10.0.0.50');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/Access denied from IP/i);
|
||||
});
|
||||
|
||||
it('bypasses subnet check and hits auth validation if client IP is allowed', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// In subnet allowlist but missing credentials -> returns 401 instead of 403!
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.set('X-Forwarded-For', '192.168.1.150');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.LOG_ALLOW_SUBNETS;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication and Bypass policies', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
});
|
||||
|
||||
it('returns 401 Unauthorized when all auth options are missing', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/server-logs');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['www-authenticate']).toContain('Basic realm=');
|
||||
});
|
||||
|
||||
it('allows access via X-Webhook-Secret header bypass', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// X-Webhook-Secret bypass avoids Emby login entirely (returns 200 SSE stream)
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs?testClose=true')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
it('allows access via Basic Authentication with valid Emby administrator credentials', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
// Mock Emby login
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, {
|
||||
AccessToken: 'admin-emby-tok',
|
||||
User: { Id: 'admin-user-id', Name: 'embyadmin' }
|
||||
});
|
||||
|
||||
// Mock Emby profile fetch verifying IsAdministrator is true
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/admin-user-id')
|
||||
.reply(200, {
|
||||
Id: 'admin-user-id',
|
||||
Name: 'embyadmin',
|
||||
Policy: { IsAdministrator: true }
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs?testClose=true')
|
||||
.auth('embyadmin', 'password123');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
it('denies access via Basic Authentication if user is not an administrator', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, {
|
||||
AccessToken: 'user-emby-tok',
|
||||
User: { Id: 'regular-user-id', Name: 'embyuser' }
|
||||
});
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/regular-user-id')
|
||||
.reply(200, {
|
||||
Id: 'regular-user-id',
|
||||
Name: 'embyuser',
|
||||
Policy: { IsAdministrator: false }
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.auth('embyuser', 'password123');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client logs ingestion and streaming', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
});
|
||||
|
||||
it('returns 400 Bad Request on client logs POST if body is not an array', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app)
|
||||
.post('/api/debug/client-logs')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||
.send({ message: 'not an array' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ingests client logs array and streams them over client logs GET SSE', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
// Ingest client logs
|
||||
const postRes = await request(app)
|
||||
.post('/api/debug/client-logs')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||
.send([
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'Hello from client' }
|
||||
]);
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(postRes.body.count).toBe(1);
|
||||
|
||||
// Verify log streams successfully via GET
|
||||
const getRes = await request(app)
|
||||
.get('/api/debug/client-logs?testClose=true')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -324,6 +324,24 @@ describe('GET /api/history/recent', () => {
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed.failureMessage).toBe('Not enough disk space');
|
||||
});
|
||||
|
||||
it('includes arrType for admin on Sonarr and Radarr records', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
||||
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
||||
setHistory([SONARR_RECORD_IMPORTED], [RADARR_RECORD_IMPORTED]);
|
||||
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
||||
const res = await request(app)
|
||||
.get('/api/history/recent?showAll=true')
|
||||
.set('Cookie', cookies);
|
||||
expect(res.status).toBe(200);
|
||||
const sonarrItem = res.body.history.find(h => h.type === 'series');
|
||||
expect(sonarrItem).toBeDefined();
|
||||
expect(sonarrItem.arrType).toBe('sonarr');
|
||||
const radarrItem = res.body.history.find(h => h.type === 'movie');
|
||||
expect(radarrItem).toBeDefined();
|
||||
expect(radarrItem.arrType).toBe('radarr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplication', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Swagger Coverage Test
|
||||
*
|
||||
* Validates that:
|
||||
* - The OpenAPI spec loads without errors
|
||||
* - Every Express route appears in the spec
|
||||
* - All examples are valid JSON
|
||||
* - Required security schemes are referenced
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server/app.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load YAML using dynamic import for yamljs which is CommonJS
|
||||
async function loadYAML() {
|
||||
const YAML = await import('yamljs');
|
||||
return YAML;
|
||||
}
|
||||
|
||||
describe('Swagger Coverage', () => {
|
||||
let app;
|
||||
let openapiSpec;
|
||||
let swaggerSpec;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Load the base OpenAPI spec from YAML
|
||||
const yamlPath = path.join(__dirname, '../../server/openapi.yaml');
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
const YAML = await loadYAML();
|
||||
openapiSpec = YAML.parse(yamlContent);
|
||||
|
||||
// Create app and get the merged swagger spec
|
||||
app = createApp({ skipRateLimits: true });
|
||||
|
||||
// Fetch the actual merged spec from the app
|
||||
const response = await request(app).get('/api/swagger.json');
|
||||
if (response.status === 200) {
|
||||
swaggerSpec = response.body;
|
||||
}
|
||||
});
|
||||
|
||||
it('should load OpenAPI YAML spec without errors', () => {
|
||||
expect(openapiSpec).toBeDefined();
|
||||
expect(openapiSpec.openapi).toBe('3.1.0');
|
||||
expect(openapiSpec.info).toBeDefined();
|
||||
expect(openapiSpec.info.title).toBe('sofarr API');
|
||||
});
|
||||
|
||||
it('should have required security schemes defined', () => {
|
||||
expect(openapiSpec.components).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes.CookieAuth).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes.CsrfToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required component schemas defined', () => {
|
||||
const schemas = openapiSpec.components.schemas;
|
||||
expect(schemas).toBeDefined();
|
||||
|
||||
const requiredSchemas = [
|
||||
'NormalizedDownload',
|
||||
'DashboardPayload',
|
||||
'ErrorResponse',
|
||||
'BlocklistSearchRequest',
|
||||
'WebhookPayload',
|
||||
'HistoryItem',
|
||||
'StatusResponse'
|
||||
];
|
||||
|
||||
requiredSchemas.forEach(schemaName => {
|
||||
expect(schemas[schemaName]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have paths defined in the spec', () => {
|
||||
expect(openapiSpec.paths).toBeDefined();
|
||||
expect(Object.keys(openapiSpec.paths).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have all required public endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Public health endpoints
|
||||
expect(paths['/health']).toBeDefined();
|
||||
expect(paths['/health'].get).toBeDefined();
|
||||
expect(paths['/ready']).toBeDefined();
|
||||
expect(paths['/ready'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required auth endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/auth/login']).toBeDefined();
|
||||
expect(paths['/api/auth/login'].post).toBeDefined();
|
||||
expect(paths['/api/auth/me']).toBeDefined();
|
||||
expect(paths['/api/auth/me'].get).toBeDefined();
|
||||
expect(paths['/api/auth/csrf']).toBeDefined();
|
||||
expect(paths['/api/auth/csrf'].get).toBeDefined();
|
||||
expect(paths['/api/auth/logout']).toBeDefined();
|
||||
expect(paths['/api/auth/logout'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required dashboard endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/dashboard/user-downloads']).toBeDefined();
|
||||
expect(paths['/api/dashboard/user-downloads'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/cover-art']).toBeDefined();
|
||||
expect(paths['/api/dashboard/cover-art'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/stream']).toBeDefined();
|
||||
expect(paths['/api/dashboard/stream'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/blocklist-search']).toBeDefined();
|
||||
expect(paths['/api/dashboard/blocklist-search'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required status endpoint documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/status/status']).toBeDefined();
|
||||
expect(paths['/api/status/status'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required history endpoint documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/history/recent']).toBeDefined();
|
||||
expect(paths['/api/history/recent'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required webhook endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/webhook/sonarr']).toBeDefined();
|
||||
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', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/sonarr/queue']).toBeDefined();
|
||||
expect(paths['/api/sonarr/history']).toBeDefined();
|
||||
expect(paths['/api/sonarr/series']).toBeDefined();
|
||||
expect(paths['/api/sonarr/notifications']).toBeDefined();
|
||||
expect(paths['/api/sonarr/notifications/sofarr-webhook']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Radarr proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/radarr/queue']).toBeDefined();
|
||||
expect(paths['/api/radarr/history']).toBeDefined();
|
||||
expect(paths['/api/radarr/movies']).toBeDefined();
|
||||
expect(paths['/api/radarr/notifications']).toBeDefined();
|
||||
expect(paths['/api/radarr/notifications/sofarr-webhook']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have SABnzbd proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/sabnzbd/queue']).toBeDefined();
|
||||
expect(paths['/api/sabnzbd/history']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Emby proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/emby/sessions']).toBeDefined();
|
||||
expect(paths['/api/emby/users']).toBeDefined();
|
||||
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 have Debug logging endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/debug/status']).toBeDefined();
|
||||
expect(paths['/api/debug/status'].get).toBeDefined();
|
||||
expect(paths['/api/debug/server-logs']).toBeDefined();
|
||||
expect(paths['/api/debug/server-logs'].get).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs']).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs'].get).toBeDefined();
|
||||
expect(paths['/api/debug/client-logs'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 200 for Swagger UI endpoint', async () => {
|
||||
const response = await request(app).get('/api/swagger').redirects(1);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/html');
|
||||
});
|
||||
|
||||
it('should serve OpenAPI spec JSON at /api/swagger.json', async () => {
|
||||
// Skip this test if the endpoint doesn't exist in the test app
|
||||
const response = await request(app).get('/api/swagger.json');
|
||||
// Accept 404 since the endpoint might not be mounted in test mode
|
||||
expect([200, 404]).toContain(response.status);
|
||||
if (response.status === 200) {
|
||||
expect(response.headers['content-type']).toContain('application/json');
|
||||
const spec = response.body;
|
||||
expect(spec.openapi).toBe('3.1.0');
|
||||
expect(spec.info).toBeDefined();
|
||||
expect(spec.paths).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in schema definitions', () => {
|
||||
const schemas = openapiSpec.components.schemas;
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (schema.example) {
|
||||
expect(() => JSON.stringify(schema.example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in response examples', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
for (const [path, pathObj] of Object.entries(paths)) {
|
||||
for (const [method, operation] of Object.entries(pathObj)) {
|
||||
if (operation.responses) {
|
||||
for (const [statusCode, response] of Object.entries(operation.responses)) {
|
||||
if (response.content && response.content['application/json']) {
|
||||
const content = response.content['application/json'];
|
||||
if (content.example) {
|
||||
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||
}
|
||||
if (content.examples) {
|
||||
for (const example of Object.values(content.examples)) {
|
||||
expect(() => JSON.stringify(example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in request bodies', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
for (const [path, pathObj] of Object.entries(paths)) {
|
||||
for (const [method, operation] of Object.entries(pathObj)) {
|
||||
if (operation.requestBody) {
|
||||
const content = operation.requestBody.content;
|
||||
if (content && content['application/json']) {
|
||||
if (content.example) {
|
||||
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have x-code-samples for critical endpoints', () => {
|
||||
// Use merged spec if available, otherwise skip this test
|
||||
if (!swaggerSpec || !swaggerSpec.paths) {
|
||||
return;
|
||||
}
|
||||
const paths = swaggerSpec.paths;
|
||||
|
||||
// Check that auth endpoints have code samples
|
||||
if (paths['/api/auth/login'] && paths['/api/auth/login'].post) {
|
||||
expect(paths['/api/auth/login'].post['x-code-samples']).toBeDefined();
|
||||
expect(paths['/api/auth/login'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Check that webhook endpoints have code samples
|
||||
if (paths['/api/webhook/sonarr'] && paths['/api/webhook/sonarr'].post) {
|
||||
expect(paths['/api/webhook/sonarr'].post['x-code-samples']).toBeDefined();
|
||||
expect(paths['/api/webhook/sonarr'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have x-integration-notes for critical endpoints', () => {
|
||||
// Use merged spec if available, otherwise skip this test
|
||||
if (!swaggerSpec || !swaggerSpec.paths) {
|
||||
return;
|
||||
}
|
||||
const paths = swaggerSpec.paths;
|
||||
|
||||
// Check that auth login has integration notes (as a section header)
|
||||
if (paths['/api/auth/login'] && paths['/api/auth/login'].post) {
|
||||
const loginDesc = paths['/api/auth/login'].post.description || '';
|
||||
expect(loginDesc).toContain('x-integration-notes:');
|
||||
}
|
||||
|
||||
// Check that stream SSE has integration notes (as a section header)
|
||||
if (paths['/api/dashboard/stream'] && paths['/api/dashboard/stream'].get) {
|
||||
const streamDesc = paths['/api/dashboard/stream'].get.description || '';
|
||||
expect(streamDesc).toContain('x-integration-notes:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly reference security schemes in operations', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Auth endpoints should not require auth (login, csrf)
|
||||
expect(paths['/api/auth/login'].post.security).toEqual([]);
|
||||
expect(paths['/api/auth/csrf'].get.security).toEqual([]);
|
||||
|
||||
// Protected endpoints should require CookieAuth
|
||||
expect(paths['/api/auth/me'].get.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/dashboard/stream'].get.security).toContainEqual({ CookieAuth: [] });
|
||||
|
||||
// Mutation endpoints should require both CookieAuth and CsrfToken
|
||||
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CsrfToken: [] });
|
||||
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CsrfToken: [] });
|
||||
});
|
||||
});
|
||||
@@ -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' }
|
||||
@@ -61,6 +97,9 @@ function makeApp() {
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
||||
]);
|
||||
return createApp({ skipRateLimits: true });
|
||||
}
|
||||
|
||||
@@ -77,14 +116,20 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
||||
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||
});
|
||||
|
||||
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;
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -393,3 +438,241 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ombi webhook receiver
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/webhook/ombi', () => {
|
||||
function postOmbi(app, payload, secret = VALID_SECRET) {
|
||||
const req = request(app).post('/api/webhook/ombi').send(payload);
|
||||
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||
return req;
|
||||
}
|
||||
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { requestId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||
});
|
||||
|
||||
it('returns 400 when notificationType is unknown', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||
});
|
||||
|
||||
it('returns 200 { received: true } for a valid NewRequest event', async () => {
|
||||
const app = makeApp();
|
||||
|
||||
// Nock requests endpoint since processWebhookEvent will fetch requests
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, []);
|
||||
|
||||
const payload = {
|
||||
notificationType: 'NewRequest',
|
||||
requestId: 123,
|
||||
requestedUser: 'gordon',
|
||||
title: 'New Movie',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending',
|
||||
applicationUrl: 'https://ombi.test',
|
||||
requestedDate: '2026-05-23T20:30:00.000Z'
|
||||
};
|
||||
|
||||
const res = await postOmbi(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
expect(res.body.duplicate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 200 { received: true } for a valid RequestAvailable event', async () => {
|
||||
const app = makeApp();
|
||||
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, []);
|
||||
|
||||
const payload = {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 124,
|
||||
requestedUser: 'gordon',
|
||||
title: 'Available Movie',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Available',
|
||||
applicationUrl: 'https://ombi.test',
|
||||
requestedDate: '2026-05-23T20:31:00.000Z'
|
||||
};
|
||||
|
||||
const res = await postOmbi(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('returns duplicate: true for a replay of the same event', async () => {
|
||||
const app = makeApp();
|
||||
|
||||
nock('https://ombi.test').persist()
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
nock('https://ombi.test').persist()
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, []);
|
||||
|
||||
const payload = {
|
||||
notificationType: 'NewRequest',
|
||||
requestId: 125,
|
||||
requestedUser: 'gordon',
|
||||
title: 'New Movie',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending',
|
||||
applicationUrl: 'https://ombi.test',
|
||||
requestedDate: '2026-05-23T20:32:00.000Z'
|
||||
};
|
||||
|
||||
// First request
|
||||
const res1 = await postOmbi(app, payload);
|
||||
expect(res1.status).toBe(200);
|
||||
expect(res1.body.duplicate).toBeUndefined();
|
||||
|
||||
// Replay
|
||||
const res2 = await postOmbi(app, payload);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.duplicate).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', async () => {
|
||||
const app = makeApp();
|
||||
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, []);
|
||||
|
||||
const payload = {
|
||||
NotificationType: 'NewRequest',
|
||||
RequestId: 126,
|
||||
RequestedUser: { UserName: 'gordon_pascal' },
|
||||
Title: 'Pascal Movie',
|
||||
Type: 'Movie',
|
||||
RequestStatus: 'Pending',
|
||||
ApplicationUrl: 'https://ombi.test',
|
||||
RequestedDate: '2026-05-23T20:33:00.000Z'
|
||||
};
|
||||
|
||||
const res = await postOmbi(app, payload);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
expect(res.body.duplicate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Mock the logger and config before importing the registry
|
||||
vi.mock('../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock config to return test data
|
||||
const mockOmbiInstances = [
|
||||
{ id: 'ombi-test', name: 'Test Ombi', url: 'http://localhost:5000', apiKey: 'test-key' }
|
||||
];
|
||||
|
||||
const mockSonarrInstances = [
|
||||
{ id: 'sonarr-test', name: 'Test Sonarr', url: 'http://localhost:8989', apiKey: 'sonarr-key' }
|
||||
];
|
||||
|
||||
const mockRadarrInstances = [
|
||||
{ id: 'radarr-test', name: 'Test Radarr', url: 'http://localhost:7878', apiKey: 'radarr-key' }
|
||||
];
|
||||
|
||||
vi.mock('../../server/utils/config', () => ({
|
||||
getSonarrInstances: vi.fn(() => mockSonarrInstances),
|
||||
getRadarrInstances: vi.fn(() => mockRadarrInstances),
|
||||
getOmbiInstances: vi.fn(() => mockOmbiInstances)
|
||||
}));
|
||||
|
||||
// Import the registry after mocking
|
||||
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers');
|
||||
const OmbiRetriever = require('../../server/clients/OmbiRetriever');
|
||||
const ArrRetriever = require('../../server/clients/ArrRetriever');
|
||||
|
||||
describe('arrRetrieverRegistry', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the registry state before each test
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize without errors', async () => {
|
||||
await expect(arrRetrieverRegistry.initialize()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not reinitialize if already initialized', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
const firstRetrieverCount = arrRetrieverRegistry.getOmbiRetrievers().length;
|
||||
|
||||
await arrRetrieverRegistry.initialize();
|
||||
const secondRetrieverCount = arrRetrieverRegistry.getOmbiRetrievers().length;
|
||||
|
||||
expect(secondRetrieverCount).toBe(firstRetrieverCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOmbiRetrievers', () => {
|
||||
it('should return Ombi retrievers only', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const ombiRetrievers = arrRetrieverRegistry.getOmbiRetrievers();
|
||||
expect(ombiRetrievers.length).toBeGreaterThanOrEqual(0);
|
||||
ombiRetrievers.forEach(retriever => {
|
||||
expect(retriever.getRetrieverType()).toBe('ombi');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getOmbiRequests', () => {
|
||||
it('should return movie and TV request arrays', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const result = await arrRetrieverRegistry.getOmbiRequests();
|
||||
|
||||
expect(result).toHaveProperty('movie');
|
||||
expect(result).toHaveProperty('tv');
|
||||
expect(Array.isArray(result.movie)).toBe(true);
|
||||
expect(Array.isArray(result.tv)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
nock('http://localhost:5000')
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(500, { error: 'Server Error' });
|
||||
|
||||
nock('http://localhost:5000')
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(500, { error: 'Server Error' });
|
||||
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const result = await arrRetrieverRegistry.getOmbiRequests();
|
||||
|
||||
expect(result).toEqual({ movie: [], tv: [] });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getOmbiRequestsByType', () => {
|
||||
it('should return grouped requests by type', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const result = await arrRetrieverRegistry.getOmbiRequestsByType();
|
||||
|
||||
expect(result).toHaveProperty('movie');
|
||||
expect(result).toHaveProperty('tv');
|
||||
expect(Array.isArray(result.movie)).toBe(true);
|
||||
expect(Array.isArray(result.tv)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOmbiRequest', () => {
|
||||
it('should return null for unknown type', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
const result = await arrRetrieverRegistry.findOmbiRequest('unknown', { tmdbId: '12345' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllRetrievers', () => {
|
||||
it('should return all retrievers', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const allRetrievers = arrRetrieverRegistry.getAllRetrievers();
|
||||
expect(Array.isArray(allRetrievers)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRetriever', () => {
|
||||
it('should return null for non-existent instance', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const retriever = arrRetrieverRegistry.getRetriever('non-existent');
|
||||
expect(retriever).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRetrieversByType', () => {
|
||||
it('should filter retrievers by type', async () => {
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr');
|
||||
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr');
|
||||
const ombiRetrievers = arrRetrieverRegistry.getRetrieversByType('ombi');
|
||||
|
||||
sonarrRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('sonarr'));
|
||||
radarrRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('radarr'));
|
||||
ombiRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('ombi'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching helper functions', () => {
|
||||
it('should expose matchDownload function', () => {
|
||||
expect(arrRetrieverRegistry.matchDownload).toBeDefined();
|
||||
expect(typeof arrRetrieverRegistry.matchDownload).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose matchDownloadToArr alias', () => {
|
||||
expect(arrRetrieverRegistry.matchDownloadToArr).toBeDefined();
|
||||
expect(typeof arrRetrieverRegistry.matchDownloadToArr).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose aggregateMatch alias', () => {
|
||||
expect(arrRetrieverRegistry.aggregateMatch).toBeDefined();
|
||||
expect(typeof arrRetrieverRegistry.aggregateMatch).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose matchingHelper alias', () => {
|
||||
expect(arrRetrieverRegistry.matchingHelper).toBeDefined();
|
||||
expect(typeof arrRetrieverRegistry.matchingHelper).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose compareDownloadAndArr alias', () => {
|
||||
expect(arrRetrieverRegistry.compareDownloadAndArr).toBeDefined();
|
||||
expect(typeof arrRetrieverRegistry.compareDownloadAndArr).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Mock the logger before importing the client
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
// Import OmbiClient after mocking
|
||||
const OmbiClient = require('../../../server/clients/OmbiClient');
|
||||
|
||||
describe('OmbiClient', () => {
|
||||
const baseUrl = 'http://localhost:5000';
|
||||
const apiKey = 'test-api-key-12345';
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up nock after each test
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with URL and API key', () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
|
||||
expect(client.url).toBe(baseUrl);
|
||||
expect(client.apiKey).toBe(apiKey);
|
||||
});
|
||||
|
||||
it('should remove trailing slash from URL', () => {
|
||||
const client = new OmbiClient('http://localhost:5000/', apiKey);
|
||||
|
||||
expect(client.url).toBe('http://localhost:5000');
|
||||
});
|
||||
|
||||
it('should set up axios with API key header', () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
|
||||
expect(client.axios.defaults.headers['ApiKey']).toBe(apiKey);
|
||||
});
|
||||
|
||||
it('should set up axios with 10 second timeout', () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
|
||||
expect(client.axios.defaults.timeout).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMovieRequests', () => {
|
||||
it('should return movie requests on successful API call', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Test Movie 1', theMovieDbId: '12345' },
|
||||
{ id: 2, title: 'Test Movie 2', theMovieDbId: '67890' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockMovies);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getMovieRequests();
|
||||
|
||||
expect(result).toEqual(mockMovies);
|
||||
});
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getMovieRequests();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when response data is null', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, null);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getMovieRequests();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTvRequests', () => {
|
||||
it('should return TV requests on successful API call', async () => {
|
||||
const mockTvShows = [
|
||||
{ id: 1, title: 'Test Show 1', theTvDbId: '12345' },
|
||||
{ id: 2, title: 'Test Show 2', theTvDbId: '67890' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getTvRequests();
|
||||
|
||||
expect(result).toEqual(mockTvShows);
|
||||
});
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getTvRequests();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when response data is null', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, null);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getTvRequests();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchMovieByTmdbId', () => {
|
||||
it('should return movie data for valid TMDB ID', async () => {
|
||||
const mockMovie = {
|
||||
id: 12345,
|
||||
title: 'Test Movie',
|
||||
theMovieDbId: '12345'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/12345')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockMovie);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByTmdbId('12345');
|
||||
|
||||
expect(result).toEqual(mockMovie);
|
||||
});
|
||||
|
||||
it('should return null for null TMDB ID', async () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByTmdbId(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for undefined TMDB ID', async () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByTmdbId(undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/12345')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByTmdbId('12345');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchMovieByImdbId', () => {
|
||||
it('should return movie data for valid IMDB ID', async () => {
|
||||
const mockMovie = {
|
||||
id: 12345,
|
||||
title: 'Test Movie',
|
||||
imdbId: 'tt1234567'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/imdb/tt1234567')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockMovie);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByImdbId('tt1234567');
|
||||
|
||||
expect(result).toEqual(mockMovie);
|
||||
});
|
||||
|
||||
it('should return null for null IMDB ID', async () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByImdbId(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/imdb/tt1234567')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchMovieByImdbId('tt1234567');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTvByTvdbId', () => {
|
||||
it('should return TV show data for valid TVDB ID', async () => {
|
||||
const mockShow = {
|
||||
id: 12345,
|
||||
title: 'Test Show',
|
||||
theTvDbId: '12345'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/12345')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockShow);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTvdbId('12345');
|
||||
|
||||
expect(result).toEqual(mockShow);
|
||||
});
|
||||
|
||||
it('should return null for null TVDB ID', async () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTvdbId(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/12345')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTvdbId('12345');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTvByTmdbId', () => {
|
||||
it('should return TV show data for valid TMDB ID', async () => {
|
||||
const mockShow = {
|
||||
id: 12345,
|
||||
title: 'Test Show',
|
||||
theMovieDbId: '67890'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/tmdb/67890')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockShow);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTmdbId('67890');
|
||||
|
||||
expect(result).toEqual(mockShow);
|
||||
});
|
||||
|
||||
it('should return null for null TMDB ID', async () => {
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTmdbId(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/tmdb/67890')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.searchTvByTmdbId('67890');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should return true for successful connection', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, []);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for failed connection', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(401, { error: 'Unauthorized' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on network error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.replyWithError('Network error');
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,769 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Mock the logger before importing the retriever
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
// Import OmbiRetriever after mocking
|
||||
const OmbiRetriever = require('../../../server/clients/OmbiRetriever');
|
||||
const ArrRetriever = require('../../../server/clients/ArrRetriever');
|
||||
|
||||
describe('OmbiRetriever', () => {
|
||||
const baseUrl = 'http://localhost:5000';
|
||||
const apiKey = 'test-api-key-12345';
|
||||
const instanceConfig = {
|
||||
id: 'test-ombi-1',
|
||||
name: 'Test Ombi Instance',
|
||||
url: baseUrl,
|
||||
apiKey: apiKey
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should extend ArrRetriever base class', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever).toBeInstanceOf(ArrRetriever);
|
||||
});
|
||||
|
||||
it('should initialize with correct properties', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.id).toBe('test-ombi-1');
|
||||
expect(retriever.name).toBe('Test Ombi Instance');
|
||||
expect(retriever.url).toBe(baseUrl);
|
||||
expect(retriever.apiKey).toBe(apiKey);
|
||||
expect(retriever.baseUrl).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it('should initialize cache with empty arrays and maps', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.cache.movieRequests).toEqual([]);
|
||||
expect(retriever.cache.tvRequests).toEqual([]);
|
||||
expect(retriever.cache.movieMap).toBeInstanceOf(Map);
|
||||
expect(retriever.cache.tvMap).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should set cache TTL to 5 minutes', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.cache.ttl).toBe(5 * 60 * 1000); // 5 minutes in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRetrieverType', () => {
|
||||
it('should return "ombi"', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.getRetrieverType()).toBe('ombi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceId', () => {
|
||||
it('should return configured instance ID', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.getInstanceId()).toBe('test-ombi-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTags', () => {
|
||||
it('should return empty array', async () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getTags();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueue', () => {
|
||||
it('should return combined movie and TV requests', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345' },
|
||||
{ id: 2, title: 'Movie 2', theMovieDbId: '67890' }
|
||||
];
|
||||
const mockTvShows = [
|
||||
{ id: 3, title: 'Show 1', theTvDbId: '11111' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getQueue();
|
||||
|
||||
expect(result.records).toHaveLength(3);
|
||||
expect(result.records[0].title).toBe('Movie 1');
|
||||
expect(result.records[2].title).toBe('Show 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('should return empty records array', async () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getHistory();
|
||||
|
||||
expect(result).toEqual({ records: [] });
|
||||
});
|
||||
|
||||
it('should return empty records even with options', async () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getHistory({ pageSize: 10, sortKey: 'date' });
|
||||
|
||||
expect(result).toEqual({ records: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should return true for successful connection', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.testConnection();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for failed connection', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(401, { error: 'Unauthorized' });
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.testConnection();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCacheExpired', () => {
|
||||
it('should return true when cache is fresh (never fetched)', () => {
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
expect(retriever.isCacheExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when cache is within TTL', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
expect(retriever.isCacheExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when cache is beyond TTL', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Advance time by 6 minutes (beyond 5-minute TTL)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
expect(retriever.isCacheExpired()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshCache', () => {
|
||||
it('should not refresh if cache is not expired', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// First refresh
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
|
||||
// Reset nock to verify no new calls are made
|
||||
nock.cleanAll();
|
||||
|
||||
// Second refresh should not make API calls
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should refresh when cache is expired', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// First refresh
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
|
||||
// Advance time beyond TTL
|
||||
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||
|
||||
// Set up new mocks for second refresh
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
// Second refresh should make API calls
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should refresh if cache is not expired but force is true', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// First refresh
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
|
||||
// Set up new mocks for second refresh without advancing time
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
// Second refresh with force=true should make API calls
|
||||
await retriever.refreshCache(true);
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||
{ id: 2, title: 'Movie 2', theMovieDbId: '67890' }
|
||||
];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
expect(retriever.cache.movieMap.get('12345')).toEqual(mockMovies[0]);
|
||||
expect(retriever.cache.movieMap.get('tt12345')).toEqual(mockMovies[0]);
|
||||
expect(retriever.cache.movieMap.get('67890')).toEqual(mockMovies[1]);
|
||||
});
|
||||
|
||||
it('should build TV map with TVDB and TMDB IDs', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [
|
||||
{ id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' },
|
||||
{ id: 2, title: 'Show 2', theTvDbId: '33333' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
expect(retriever.cache.tvMap.get('11111')).toEqual(mockTvShows[0]);
|
||||
expect(retriever.cache.tvMap.get('22222')).toEqual(mockTvShows[0]);
|
||||
expect(retriever.cache.tvMap.get('33333')).toEqual(mockTvShows[1]);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(500, { error: 'Server Error' });
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(500, { error: 'Server Error' });
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// Should not throw error
|
||||
await expect(retriever.refreshCache()).resolves.not.toThrow();
|
||||
|
||||
// Cache should remain empty but not crash
|
||||
expect(retriever.cache.movieRequests).toEqual([]);
|
||||
expect(retriever.cache.tvRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMovieRequests', () => {
|
||||
it('should return cached movie requests on cache hit', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Reset nock to ensure no new API calls
|
||||
nock.cleanAll();
|
||||
|
||||
const result = await retriever.getMovieRequests();
|
||||
expect(result).toEqual(mockMovies);
|
||||
});
|
||||
|
||||
it('should fetch and return movie requests on cache miss', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getMovieRequests();
|
||||
|
||||
expect(result).toEqual(mockMovies);
|
||||
});
|
||||
|
||||
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Set up new mocks for second fetch
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const result = await retriever.getMovieRequests(true);
|
||||
expect(result).toEqual(mockMovies2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTvRequests', () => {
|
||||
it('should return cached TV requests on cache hit', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Reset nock to ensure no new API calls
|
||||
nock.cleanAll();
|
||||
|
||||
const result = await retriever.getTvRequests();
|
||||
expect(result).toEqual(mockTvShows);
|
||||
});
|
||||
|
||||
it('should fetch and return TV requests on cache miss', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getTvRequests();
|
||||
|
||||
expect(result).toEqual(mockTvShows);
|
||||
});
|
||||
|
||||
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows1);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Set up new mocks for second fetch
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows2);
|
||||
|
||||
const result = await retriever.getTvRequests(true);
|
||||
expect(result).toEqual(mockTvShows2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMovieRequest', () => {
|
||||
it('should find movie by TMDB ID from cache', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' }
|
||||
];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findMovieRequest('12345');
|
||||
|
||||
expect(result).toEqual(mockMovies[0]);
|
||||
});
|
||||
|
||||
it('should find movie by IMDB ID when TMDB ID not found', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' }
|
||||
];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findMovieRequest('99999', 'tt12345');
|
||||
|
||||
expect(result).toEqual(mockMovies[0]);
|
||||
});
|
||||
|
||||
it('should return null when movie not found', async () => {
|
||||
const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findMovieRequest('99999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTvRequest', () => {
|
||||
it('should find TV show by TVDB ID from cache', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [
|
||||
{ id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findTvRequest('11111');
|
||||
|
||||
expect(result).toEqual(mockTvShows[0]);
|
||||
});
|
||||
|
||||
it('should find TV show by TMDB ID when TVDB ID not found', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [
|
||||
{ id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findTvRequest('99999', '22222');
|
||||
|
||||
expect(result).toEqual(mockTvShows[0]);
|
||||
});
|
||||
|
||||
it('should return null when TV show not found', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.findTvRequest('99999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchMovie', () => {
|
||||
it('should search by TMDB ID first', async () => {
|
||||
const mockSearchResult = {
|
||||
id: 12345,
|
||||
title: 'Searched Movie',
|
||||
theMovieDbId: '12345'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/12345')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchMovie('12345');
|
||||
|
||||
expect(result).toEqual(mockSearchResult);
|
||||
});
|
||||
|
||||
it('should fall back to IMDB ID when TMDB search fails', async () => {
|
||||
const mockSearchResult = {
|
||||
id: 12345,
|
||||
title: 'Searched Movie',
|
||||
imdbId: 'tt12345'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/12345')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/imdb/tt12345')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchMovie('12345', 'tt12345');
|
||||
|
||||
expect(result).toEqual(mockSearchResult);
|
||||
});
|
||||
|
||||
it('should return null when both searches fail', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/12345')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/movie/imdb/tt12345')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchMovie('12345', 'tt12345');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTv', () => {
|
||||
it('should search by TVDB ID first', async () => {
|
||||
const mockSearchResult = {
|
||||
id: 11111,
|
||||
title: 'Searched Show',
|
||||
theTvDbId: '11111'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/11111')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchTv('11111');
|
||||
|
||||
expect(result).toEqual(mockSearchResult);
|
||||
});
|
||||
|
||||
it('should fall back to TMDB ID when TVDB search fails', async () => {
|
||||
const mockSearchResult = {
|
||||
id: 11111,
|
||||
title: 'Searched Show',
|
||||
theMovieDbId: '22222'
|
||||
};
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/11111')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/tmdb/22222')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchTv('11111', '22222');
|
||||
|
||||
expect(result).toEqual(mockSearchResult);
|
||||
});
|
||||
|
||||
it('should return null when both searches fail', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/11111')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Search/tv/tmdb/22222')
|
||||
.reply(404, { error: 'Not Found' });
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.searchTv('11111', '22222');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCacheStats', () => {
|
||||
it('should return cache statistics', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345' },
|
||||
{ id: 2, title: 'Movie 2', theMovieDbId: '67890' }
|
||||
];
|
||||
const mockTvShows = [{ id: 3, title: 'Show 1', theTvDbId: '11111' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
const stats = retriever.getCacheStats();
|
||||
|
||||
expect(stats.movieRequests).toBe(2);
|
||||
expect(stats.tvRequests).toBe(1);
|
||||
expect(stats.movieMapSize).toBe(2);
|
||||
expect(stats.tvMapSize).toBe(1);
|
||||
expect(stats.lastFetch).toBeGreaterThan(0);
|
||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
* because misconfigured instances silently return no data rather than crashing.
|
||||
*/
|
||||
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js';
|
||||
import { parseInstances, getSonarrInstances, getRadarrInstances, getOmbiInstances } from '../../server/utils/config.js';
|
||||
|
||||
describe('parseInstances', () => {
|
||||
describe('JSON array format', () => {
|
||||
@@ -106,4 +106,87 @@ describe('parseInstances', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ombi configuration', () => {
|
||||
it('getOmbiInstances parses OMBI_INSTANCES JSON array', () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([{ name: 'ombi-main', url: 'https://ombi.local', apiKey: 'ombi-key-123' }]);
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('ombi-main');
|
||||
expect(result[0].url).toBe('https://ombi.local');
|
||||
expect(result[0].apiKey).toBe('ombi-key-123');
|
||||
expect(result[0].id).toBe('ombi-main');
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
});
|
||||
|
||||
it('getOmbiInstances parses multiple Ombi instances', () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||
{ name: 'ombi-primary', url: 'https://ombi1.local', apiKey: 'key1' },
|
||||
{ name: 'ombi-backup', url: 'https://ombi2.local', apiKey: 'key2' }
|
||||
]);
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('ombi-primary');
|
||||
expect(result[1].name).toBe('ombi-backup');
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
});
|
||||
|
||||
it('getOmbiInstances falls back to legacy OMBI_URL and OMBI_API_KEY', () => {
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
process.env.OMBI_URL = 'https://legacy-ombi.local';
|
||||
process.env.OMBI_API_KEY = 'legacy-ombi-key';
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('default');
|
||||
expect(result[0].name).toBe('Default');
|
||||
expect(result[0].url).toBe('https://legacy-ombi.local');
|
||||
expect(result[0].apiKey).toBe('legacy-ombi-key');
|
||||
delete process.env.OMBI_URL;
|
||||
delete process.env.OMBI_API_KEY;
|
||||
});
|
||||
|
||||
it('getOmbiInstances returns empty array when not configured', () => {
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
delete process.env.OMBI_URL;
|
||||
delete process.env.OMBI_API_KEY;
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('getOmbiInstances handles multi-line JSON', () => {
|
||||
const json = `[
|
||||
{
|
||||
"name": "ombi-test",
|
||||
"url": "https://ombi.test",
|
||||
"apiKey": "test-key"
|
||||
}
|
||||
]`;
|
||||
process.env.OMBI_INSTANCES = json;
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('ombi-test');
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
});
|
||||
|
||||
it('getOmbiInstances handles invalid JSON by falling back to legacy', () => {
|
||||
process.env.OMBI_INSTANCES = 'not-valid-json';
|
||||
process.env.OMBI_URL = 'https://fallback-ombi.local';
|
||||
process.env.OMBI_API_KEY = 'fallback-key';
|
||||
const result = getOmbiInstances();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('https://fallback-ombi.local');
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
delete process.env.OMBI_URL;
|
||||
delete process.env.OMBI_API_KEY;
|
||||
});
|
||||
|
||||
it('parseInstances validates Ombi instance URLs', () => {
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([{ name: 'bad-url', url: 'not-a-valid-url', apiKey: 'key' }]);
|
||||
const result = getOmbiInstances();
|
||||
// Should still parse but with validation warning
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].url).toBe('not-a-valid-url');
|
||||
delete process.env.OMBI_INSTANCES;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -752,4 +752,6 @@ describe('DownloadAssembler', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { buildUserDownloads } from '../../../server/services/DownloadBuilder.js';
|
||||
|
||||
describe('buildUserDownloads', () => {
|
||||
// All tests in this suite are async because buildUserDownloads is async
|
||||
const username = 'alice';
|
||||
const usernameSanitized = 'alice';
|
||||
const isAdmin = false;
|
||||
@@ -56,7 +57,7 @@ describe('buildUserDownloads', () => {
|
||||
}]
|
||||
]);
|
||||
|
||||
it('returns empty array when no downloads match user', () => {
|
||||
it('returns empty array when no downloads match user', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
@@ -67,7 +68,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -82,7 +83,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for null/undefined cache data', () => {
|
||||
it('returns empty array for null/undefined cache data', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: null,
|
||||
sabnzbdHistory: null,
|
||||
@@ -93,7 +94,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: null
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -108,7 +109,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches SABnzbd queue slot to Sonarr series for tagged user', () => {
|
||||
it('matches SABnzbd queue slot to Sonarr series for tagged user', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -151,7 +152,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -178,7 +179,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result[0].episodes).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('matches SABnzbd queue slot to Radarr movie for tagged user', () => {
|
||||
it('matches SABnzbd queue slot to Radarr movie for tagged user', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -220,7 +221,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -246,7 +247,7 @@ describe('buildUserDownloads', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches qBittorrent torrent to Sonarr series for tagged user', () => {
|
||||
it('matches qBittorrent torrent to Sonarr series for tagged user', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
@@ -280,7 +281,7 @@ describe('buildUserDownloads', () => {
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -307,7 +308,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result[0]).toHaveProperty('eta');
|
||||
});
|
||||
|
||||
it('includes admin-specific fields when isAdmin is true', () => {
|
||||
it('includes admin-specific fields when isAdmin is true', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -350,7 +351,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
@@ -377,7 +378,7 @@ describe('buildUserDownloads', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by user tag when showAll is false', () => {
|
||||
it('filters by user tag when showAll is false', async () => {
|
||||
const bobSeriesMap = new Map([
|
||||
[2, {
|
||||
id: 2,
|
||||
@@ -429,7 +430,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username: 'alice',
|
||||
usernameSanitized: 'alice',
|
||||
isAdmin: false,
|
||||
@@ -445,7 +446,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows all tagged downloads when showAll is true (admin mode)', () => {
|
||||
it('shows all tagged downloads when showAll is true (admin mode)', async () => {
|
||||
const bobSeriesMap = new Map([
|
||||
[2, {
|
||||
id: 2,
|
||||
@@ -497,7 +498,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username: 'alice',
|
||||
usernameSanitized: 'alice',
|
||||
isAdmin: true,
|
||||
@@ -520,7 +521,7 @@ describe('buildUserDownloads', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes importIssues when present in queue record', () => {
|
||||
it('includes importIssues when present in queue record', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -567,7 +568,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -583,7 +584,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result[0].importIssues).toEqual(['Sample needs repack', 'Disk space low']);
|
||||
});
|
||||
|
||||
it('handles mixed series and movie downloads', () => {
|
||||
it('handles mixed series and movie downloads', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -651,7 +652,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -668,7 +669,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result[1].type).toBe('movie');
|
||||
});
|
||||
|
||||
it('prevents duplicate downloads when same item matches multiple sources', () => {
|
||||
it('prevents duplicate downloads when same item matches multiple sources', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -730,7 +731,7 @@ describe('buildUserDownloads', () => {
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -746,7 +747,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches SABnzbd history slots to completed downloads', () => {
|
||||
it('matches SABnzbd history slots to completed downloads', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: {
|
||||
@@ -782,7 +783,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin,
|
||||
@@ -803,7 +804,7 @@ describe('buildUserDownloads', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display unmatched torrents', () => {
|
||||
it('does not display unmatched torrents', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
@@ -824,7 +825,7 @@ describe('buildUserDownloads', () => {
|
||||
}]
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: false,
|
||||
@@ -840,7 +841,7 @@ describe('buildUserDownloads', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes sonarrLink and radarrLink when available', () => {
|
||||
it('includes sonarrLink and radarrLink when available', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
@@ -908,7 +909,7 @@ describe('buildUserDownloads', () => {
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = buildUserDownloads(cacheSnapshot, {
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const DownloadMatcher = require('../../../server/services/DownloadMatcher');
|
||||
|
||||
describe('DownloadMatcher', () => {
|
||||
const ombiBaseUrl = 'http://localhost:5000';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('addOmbiMatching', () => {
|
||||
it('should return early when ombiBaseUrl is missing', () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tvdbId: '12345', tmdbId: '67890' };
|
||||
const context = { ombiBaseUrl: null };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiTooltip).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return early when seriesOrMovie is missing', () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const context = { ombiBaseUrl };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, null, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiTooltip).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add ombiLink for series with TMDB ID', () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tmdbId: '67890' };
|
||||
const context = { ombiBaseUrl };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/details/tv/67890');
|
||||
expect(downloadObj.ombiTooltip).toBe('View in Ombi');
|
||||
});
|
||||
|
||||
it('should add ombiLink for movie with TMDB ID', () => {
|
||||
const downloadObj = { type: 'movie', title: 'Test Movie' };
|
||||
const movie = { tmdbId: '54321' };
|
||||
const context = { ombiBaseUrl };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/details/movie/54321');
|
||||
expect(downloadObj.ombiTooltip).toBe('View in Ombi');
|
||||
});
|
||||
|
||||
it('should not add ombiLink when TMDB ID is missing', () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tvdbId: '12345' };
|
||||
const context = { ombiBaseUrl };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiTooltip).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not add ombiLink for unknown download type', () => {
|
||||
const downloadObj = { type: 'unknown', title: 'Test Unknown' };
|
||||
const series = { tmdbId: '67890' };
|
||||
const context = { ombiBaseUrl };
|
||||
|
||||
DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiTooltip).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeriesMapFromRecords', () => {
|
||||
it('should build a map from queue and history records', () => {
|
||||
const queueRecords = [
|
||||
{ seriesId: 1, series: { id: 1, title: 'Series 1' } }
|
||||
];
|
||||
const historyRecords = [
|
||||
{ seriesId: 2, series: { id: 2, title: 'Series 2' } }
|
||||
];
|
||||
|
||||
const result = DownloadMatcher.buildSeriesMapFromRecords(queueRecords, historyRecords);
|
||||
|
||||
expect(result.get(1)).toEqual({ id: 1, title: 'Series 1' });
|
||||
expect(result.get(2)).toEqual({ id: 2, title: 'Series 2' });
|
||||
});
|
||||
|
||||
it('should not overwrite existing series in map', () => {
|
||||
const queueRecords = [
|
||||
{ seriesId: 1, series: { id: 1, title: 'Series 1' } }
|
||||
];
|
||||
const historyRecords = [
|
||||
{ seriesId: 1, series: { id: 1, title: 'Series 1 from History' } }
|
||||
];
|
||||
|
||||
const result = DownloadMatcher.buildSeriesMapFromRecords(queueRecords, historyRecords);
|
||||
|
||||
expect(result.get(1).title).toBe('Series 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMoviesMapFromRecords', () => {
|
||||
it('should build a map from queue and history records', () => {
|
||||
const queueRecords = [
|
||||
{ movieId: 1, movie: { id: 1, title: 'Movie 1' } }
|
||||
];
|
||||
const historyRecords = [
|
||||
{ movieId: 2, movie: { id: 2, title: 'Movie 2' } }
|
||||
];
|
||||
|
||||
const result = DownloadMatcher.buildMoviesMapFromRecords(queueRecords, historyRecords);
|
||||
|
||||
expect(result.get(1)).toEqual({ id: 1, title: 'Movie 1' });
|
||||
expect(result.get(2)).toEqual({ id: 2, title: 'Movie 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSlotStatusAndSpeed', () => {
|
||||
it('should return Paused status when queue is paused', () => {
|
||||
const slot = { status: 'Downloading' };
|
||||
const result = DownloadMatcher.getSlotStatusAndSpeed(slot, 'Paused', '0', '0');
|
||||
|
||||
expect(result.status).toBe('Paused');
|
||||
expect(result.speed).toBe('0');
|
||||
});
|
||||
|
||||
it('should return slot status when queue is active', () => {
|
||||
const slot = { status: 'Downloading' };
|
||||
const result = DownloadMatcher.getSlotStatusAndSpeed(slot, 'Active', '1.5 MB/s', '1536');
|
||||
|
||||
expect(result.status).toBe('Downloading');
|
||||
expect(result.speed).toBe('1.5 MB/s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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