Merge pull request 'feat(swagger): Add Swagger API reference, and fixes' (#28) from develop into main
Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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
|
||||
+95
-3
@@ -52,6 +52,7 @@ flowchart TB
|
||||
status["Status Panel\n(Admin only)"]
|
||||
history["History Tab"]
|
||||
webhooks["Webhook Config"]
|
||||
swagger["Swagger UI\n/api/swagger"]
|
||||
end
|
||||
|
||||
subgraph Server["Express Server (:3001)"]
|
||||
@@ -122,6 +123,7 @@ 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
|
||||
@@ -263,10 +265,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,12 +285,18 @@ 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>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -826,6 +839,72 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## 8. Directory Structure
|
||||
@@ -842,7 +921,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
|
||||
@@ -1047,6 +1129,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 +1139,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 |
|
||||
|
||||
+106
@@ -6,6 +6,112 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -305,6 +306,32 @@ 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
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB)
|
||||
- When a matching request is found, an Ombi icon appears in the download card
|
||||
- Clicking the icon opens the Ombi request page
|
||||
- If no request exists, a search link is provided instead
|
||||
- Integration is fully optional - sofarr works perfectly without Ombi configured
|
||||
|
||||
**External ID Matching:**
|
||||
- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback)
|
||||
- **Movies**: TMDB ID (primary) → IMDB ID (fallback)
|
||||
- Matching is performed automatically using data from Sonarr/Radarr
|
||||
|
||||
## Setting Up User Tags
|
||||
|
||||
To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||
@@ -353,6 +380,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
|
||||
|
||||
+10
-3
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,6 +167,7 @@ server {
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -346,11 +388,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 +409,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
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.1",
|
||||
"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",
|
||||
|
||||
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 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+114
@@ -11,6 +11,10 @@ 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 sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
@@ -26,6 +30,24 @@ 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)
|
||||
@@ -80,10 +102,75 @@ 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
|
||||
* 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() });
|
||||
});
|
||||
|
||||
/**
|
||||
* @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,6 +180,33 @@ 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);
|
||||
|
||||
@@ -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,260 @@
|
||||
// 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
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshCache() {
|
||||
if (!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
|
||||
* @returns {Promise<Array>} Array of movie request objects
|
||||
*/
|
||||
async getMovieRequests() {
|
||||
await this.refreshCache();
|
||||
return this.cache.movieRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV requests
|
||||
* @returns {Promise<Array>} Array of TV request objects
|
||||
*/
|
||||
async getTvRequests() {
|
||||
await this.refreshCache();
|
||||
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;
|
||||
+110
@@ -8,6 +8,9 @@ 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 { version } = require('../package.json');
|
||||
@@ -113,6 +116,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';
|
||||
|
||||
@@ -198,10 +218,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.6.0"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ready:
|
||||
* get:
|
||||
* tags: [Health]
|
||||
* summary: Readiness check
|
||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
||||
* security: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Server is ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "ready"
|
||||
* '503':
|
||||
* description: Server not ready
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: "not ready"
|
||||
* reason:
|
||||
* type: string
|
||||
* example: "EMBY_URL not configured"
|
||||
*/
|
||||
app.get('/ready', (req, res) => {
|
||||
// Confirm critical config is present
|
||||
const ready = !!(process.env.EMBY_URL);
|
||||
@@ -212,6 +293,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
|
||||
|
||||
+1428
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) {
|
||||
|
||||
+426
-24
@@ -11,6 +11,8 @@ 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 } = require('../utils/config');
|
||||
|
||||
|
||||
// Track active SSE clients for disconnect cleanup
|
||||
@@ -62,8 +64,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 +169,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 +183,9 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -103,9 +199,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 +318,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 +464,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 +478,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}`);
|
||||
@@ -230,20 +528,124 @@ 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: |
|
||||
* Admin-only endpoint that removes a queue item from Sonarr/Radarr with blocklist=true
|
||||
* (so the release is not grabbed again), then immediately triggers a new automatic search
|
||||
* for the same episode/movie.
|
||||
*
|
||||
* 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.
|
||||
* **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header.
|
||||
*
|
||||
* 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'
|
||||
* }
|
||||
* **Workflow:**
|
||||
* 1. Validate user is admin
|
||||
* 2. Validate all required fields are present
|
||||
* 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
|
||||
* - `arrContentId`: episodeId (Sonarr) or movieId (Radarr)
|
||||
* - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr)
|
||||
*
|
||||
* **Error Responses:**
|
||||
* - 403: Non-admin user attempts access
|
||||
* - 400: Missing required fields or invalid arrType
|
||||
* - 502: Failed to communicate with *arr instance
|
||||
*
|
||||
* **x-integration-notes:** This endpoint is used from the dashboard UI when an admin
|
||||
* clicks "Blocklist + Re-search" on a failed download. The arr instance credentials
|
||||
* are passed from the download object (which includes them for admin users).
|
||||
* 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: Admin access required
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* example:
|
||||
* error: "Admin access 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 {
|
||||
|
||||
+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`, {
|
||||
|
||||
+146
-30
@@ -5,7 +5,7 @@ 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');
|
||||
|
||||
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||
@@ -194,39 +194,146 @@ function getRadarrLink(movie) {
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
function getOmbiDetailsLink(mediaObj, type, ombiBaseUrl) {
|
||||
if (!ombiBaseUrl || !mediaObj) return null;
|
||||
const tmdbId = mediaObj.tmdbId;
|
||||
if (!tmdbId) return null;
|
||||
if (type === 'series') return `${ombiBaseUrl}/details/tv/${tmdbId}`;
|
||||
if (type === 'movie') return `${ombiBaseUrl}/details/movie/${tmdbId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,6 +352,9 @@ 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),
|
||||
@@ -291,6 +401,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getSonarrLink(series),
|
||||
ombiLink: getOmbiDetailsLink(series, 'series', ombiBaseUrl),
|
||||
ombiTooltip: 'View in Ombi',
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
@@ -299,6 +411,7 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
item.arrType = 'sonarr';
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
@@ -339,6 +452,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
quality,
|
||||
instanceName: record._instanceName || null,
|
||||
arrLink: getRadarrLink(movie),
|
||||
ombiLink: getOmbiDetailsLink(movie, 'movie', ombiBaseUrl),
|
||||
ombiTooltip: 'View in Ombi',
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
@@ -347,6 +462,7 @@ router.get('/recent', requireAuth, async (req, res) => {
|
||||
|
||||
if (isAdmin) {
|
||||
item.arrRecordId = record.id;
|
||||
item.arrType = 'radarr';
|
||||
if (outcome === 'failed' && record.data && record.data.message) {
|
||||
item.failureMessage = record.data.message;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,30 @@ 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
|
||||
@@ -29,6 +53,30 @@ 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) => {
|
||||
try {
|
||||
@@ -66,6 +114,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) => {
|
||||
|
||||
@@ -5,9 +5,32 @@ const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
/**
|
||||
* @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) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
@@ -23,7 +46,31 @@ 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) => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
|
||||
@@ -15,6 +15,30 @@ 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
|
||||
@@ -29,6 +53,30 @@ 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) => {
|
||||
try {
|
||||
@@ -66,6 +114,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) => {
|
||||
|
||||
+97
-1
@@ -8,7 +8,103 @@ const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||
const { checkWebhookConfigured, 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;
|
||||
|
||||
+200
-10
@@ -219,12 +219,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 +366,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)) {
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -198,6 +216,7 @@ function matchSabSlots(slots, context) {
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -250,6 +269,7 @@ function matchSabSlots(slots, context) {
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +284,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 +295,9 @@ function matchSabHistory(slots, context) {
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -317,6 +339,7 @@ function matchSabHistory(slots, context) {
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +378,7 @@ function matchSabHistory(slots, context) {
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -369,7 +393,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 +406,9 @@ function matchTorrents(torrents, context) {
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -430,6 +456,7 @@ function matchTorrents(torrents, context) {
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
@@ -474,6 +501,7 @@ function matchTorrents(torrents, context) {
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
@@ -506,6 +534,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 +570,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 +585,7 @@ module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
getSlotStatusAndSpeed,
|
||||
addOmbiMatching,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
|
||||
@@ -3,17 +3,20 @@ const { logToFile } = require('./logger');
|
||||
const cache = require('./cache');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
|
||||
// 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 +39,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,6 +308,72 @@ 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
|
||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||
*/
|
||||
async getOmbiRequests() {
|
||||
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();
|
||||
const tvRequests = await retriever.getTvRequests();
|
||||
return { movie: movieRequests, tv: tvRequests };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||
return { movie: [], tv: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Ombi requests grouped by type
|
||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||
*/
|
||||
async getOmbiRequestsByType() {
|
||||
return await this.getOmbiRequests();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// 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();
|
||||
});
|
||||
|
||||
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 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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,678 @@
|
||||
// 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 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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -752,4 +752,78 @@ describe('DownloadAssembler', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOmbiLink', () => {
|
||||
it('returns correct URL for valid requestId, type, and baseUrl', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(123, 'movie', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/request/movie/123');
|
||||
});
|
||||
|
||||
it('returns correct URL for TV type', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(456, 'tv', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/request/tv/456');
|
||||
});
|
||||
|
||||
it('returns null when requestId is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(null, 'movie', 'http://localhost:5000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when type is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(123, null, 'http://localhost:5000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when baseUrl is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(123, 'movie', null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all parameters are missing', () => {
|
||||
const result = DownloadAssembler.getOmbiLink(null, null, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles string requestId', () => {
|
||||
const result = DownloadAssembler.getOmbiLink('abc-123', 'movie', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/request/movie/abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOmbiSearchLink', () => {
|
||||
it('returns correct URL for series type', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(789, 'series', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/tv/search/789');
|
||||
});
|
||||
|
||||
it('returns correct URL for movie type', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(101, 'movie', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/movie/search/101');
|
||||
});
|
||||
|
||||
it('returns null when searchId is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(null, 'series', 'http://localhost:5000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when type is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(789, null, 'http://localhost:5000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when baseUrl is missing', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(789, 'series', null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid type', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink(789, 'invalid', 'http://localhost:5000');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles string searchId', () => {
|
||||
const result = DownloadAssembler.getOmbiSearchLink('search-123', 'movie', 'http://localhost:5000');
|
||||
expect(result).toBe('http://localhost:5000/#/movie/search/search-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,368 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import nock from 'nock';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../server/utils/logger', () => ({
|
||||
logToFile: vi.fn()
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const DownloadMatcher = require('../../../server/services/DownloadMatcher');
|
||||
const OmbiRetriever = require('../../../server/clients/OmbiRetriever');
|
||||
|
||||
describe('DownloadMatcher', () => {
|
||||
const ombiBaseUrl = 'http://localhost:5000';
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('addOmbiMatching', () => {
|
||||
it('should return early when ombiRetriever is missing', async () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tvdbId: '12345', tmdbId: '67890' };
|
||||
const context = { ombiRetriever: null, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return early when ombiBaseUrl is missing', async () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tvdbId: '12345', tmdbId: '67890' };
|
||||
|
||||
const mockRetriever = {
|
||||
findTvRequest: vi.fn(),
|
||||
searchTv: vi.fn()
|
||||
};
|
||||
|
||||
const context = { ombiRetriever: mockRetriever, ombiBaseUrl: null };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return early when seriesOrMovie is missing', async () => {
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
|
||||
const mockRetriever = {
|
||||
findTvRequest: vi.fn(),
|
||||
searchTv: vi.fn()
|
||||
};
|
||||
|
||||
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, null, context);
|
||||
|
||||
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add ombiLink and ombiRequestId for TV request found by TVDB ID', async () => {
|
||||
const mockMovieRequests = [];
|
||||
const mockTvRequests = [
|
||||
{ id: 101, title: 'Test Show', type: 'tv', theTvDbId: '12345' }
|
||||
];
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'series', title: 'Test Show' };
|
||||
const series = { tvdbId: '12345', tmdbId: '67890' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/101');
|
||||
expect(downloadObj.ombiRequestId).toBe(101);
|
||||
expect(downloadObj.ombiTooltip).toBe('Request');
|
||||
});
|
||||
|
||||
it('should add ombiLink and ombiRequestId for TV request found by TMDB ID fallback', async () => {
|
||||
const mockMovieRequests = [];
|
||||
const mockTvRequests = [
|
||||
{ id: 102, title: 'Test Show TMDB', type: 'tv', theMovieDbId: '67890' }
|
||||
];
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'series', title: 'Test Show TMDB' };
|
||||
const series = { tvdbId: '99999', tmdbId: '67890' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/102');
|
||||
expect(downloadObj.ombiRequestId).toBe(102);
|
||||
expect(downloadObj.ombiTooltip).toBe('Request');
|
||||
});
|
||||
|
||||
it('should add ombiLink and ombiRequestId for movie request found by TMDB ID', async () => {
|
||||
const mockMovieRequests = [
|
||||
{ id: 201, title: 'Test Movie', type: 'movie', theMovieDbId: '54321' }
|
||||
];
|
||||
const mockTvRequests = [];
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'movie', title: 'Test Movie' };
|
||||
const movie = { tmdbId: '54321', imdbId: 'tt54321' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/201');
|
||||
expect(downloadObj.ombiRequestId).toBe(201);
|
||||
expect(downloadObj.ombiTooltip).toBe('Request');
|
||||
});
|
||||
|
||||
it('should add ombiLink and ombiRequestId for movie request found by IMDB ID fallback', async () => {
|
||||
const mockMovieRequests = [
|
||||
{ id: 202, title: 'Test Movie IMDB', type: 'movie', imdbId: 'tt98765' }
|
||||
];
|
||||
const mockTvRequests = [];
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'movie', title: 'Test Movie IMDB' };
|
||||
const movie = { tmdbId: '99999', imdbId: 'tt98765' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/202');
|
||||
expect(downloadObj.ombiRequestId).toBe(202);
|
||||
expect(downloadObj.ombiTooltip).toBe('Request');
|
||||
});
|
||||
|
||||
it('should add search link and tooltip when no request found but search succeeds', async () => {
|
||||
const mockMovieRequests = [];
|
||||
const mockTvRequests = [];
|
||||
const mockSearchResult = { id: 12345, title: 'Test Show Search', theTvDbId: '11111' };
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Search/tv/11111')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'series', title: 'Test Show Search' };
|
||||
const series = { tvdbId: '11111', tmdbId: '22222' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, series, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/tv/search/12345');
|
||||
expect(downloadObj.ombiTooltip).toBe('Search');
|
||||
expect(downloadObj.ombiRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add movie search link for movie type', async () => {
|
||||
const mockMovieRequests = [];
|
||||
const mockTvRequests = [];
|
||||
const mockSearchResult = { id: 54321, title: 'Test Movie Search', theMovieDbId: '33333' };
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovieRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvRequests);
|
||||
|
||||
nock(ombiBaseUrl)
|
||||
.get('/api/v1/Search/movie/33333')
|
||||
.reply(200, mockSearchResult);
|
||||
|
||||
const ombiRetriever = new OmbiRetriever({
|
||||
id: 'test-ombi',
|
||||
name: 'Test Ombi',
|
||||
url: ombiBaseUrl,
|
||||
apiKey: 'test-key'
|
||||
});
|
||||
|
||||
const downloadObj = { type: 'movie', title: 'Test Movie Search' };
|
||||
const movie = { tmdbId: '33333', imdbId: 'tt33333' };
|
||||
const context = { ombiRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, movie, context);
|
||||
|
||||
expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/movie/search/54321');
|
||||
expect(downloadObj.ombiTooltip).toBe('Search');
|
||||
expect(downloadObj.ombiRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully without breaking download object', async () => {
|
||||
const mockRetriever = {
|
||||
findTvRequest: vi.fn().mockRejectedValue(new Error('Ombi API error')),
|
||||
searchTv: vi.fn().mockRejectedValue(new Error('Search error'))
|
||||
};
|
||||
|
||||
const downloadObj = { type: 'series', title: 'Test Show Error' };
|
||||
const series = { tvdbId: '66666', tmdbId: '77777' };
|
||||
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
|
||||
|
||||
// Should not throw error
|
||||
await expect(DownloadMatcher.addOmbiMatching(downloadObj, series, context)).resolves.not.toThrow();
|
||||
|
||||
// Download object should still have original data
|
||||
expect(downloadObj.title).toBe('Test Show Error');
|
||||
expect(downloadObj.ombiLink).toBeUndefined();
|
||||
expect(downloadObj.ombiRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should do nothing for unknown download type', async () => {
|
||||
const mockRetriever = {
|
||||
findTvRequest: vi.fn(),
|
||||
findMovieRequest: vi.fn()
|
||||
};
|
||||
|
||||
const downloadObj = { type: 'unknown', title: 'Unknown Type' };
|
||||
const media = { id: 123 };
|
||||
const context = { ombiRetriever: mockRetriever, ombiBaseUrl };
|
||||
|
||||
await DownloadMatcher.addOmbiMatching(downloadObj, media, context);
|
||||
|
||||
expect(mockRetriever.findTvRequest).not.toHaveBeenCalled();
|
||||
expect(mockRetriever.findMovieRequest).not.toHaveBeenCalled();
|
||||
expect(downloadObj.ombiLink).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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user