feat(ombi): Add Ombi PALDRA integration for request management
Docs Check / Markdown lint (push) Successful in 1m43s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m1s
CI / Security audit (push) Successful in 2m48s
Docs Check / Mermaid diagram parse check (push) Successful in 3m8s
CI / Tests & coverage (push) Failing after 3m33s
CI / Swagger Validation & Coverage (push) Successful in 3m34s
Build and Push Docker Image / build (push) Successful in 4m36s

- Add OmbiRetriever extending ArrRetriever for PALDRA compliance
- Add OmbiClient for low-level Ombi API communication
- Add getOmbiInstances() to config.js following multi-instance pattern
- Register Ombi in PALDRA registry with Ombi-specific methods
- Add external ID matching (TMDB/TVDB/IMDB) to Ombi requests
- Update DownloadMatcher to be async and enrich downloads with Ombi links
- Add getOmbiLink/getOmbiSearchLink helpers to DownloadAssembler
- Implement new service icon layout (Ombi + Sonarr/Radarr icons)
- Add CSS styling for service icons
- Update dashboard routes to include Ombi configuration
- Extend OpenAPI with Ombi tag and NormalizedDownload properties
- Update documentation (README, ARCHITECTURE, SECURITY, CHANGELOG)
- Add Ombi configuration to .env.sample
This commit is contained in:
2026-05-21 17:00:04 +01:00
parent de9a9284dc
commit ed4237debb
20 changed files with 850 additions and 33 deletions
+6
View File
@@ -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
# =============================================================================
+18 -3
View File
@@ -265,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.
@@ -280,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>
}
```
@@ -910,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
@@ -1115,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
+28
View File
@@ -6,6 +6,34 @@ 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
+27
View File
@@ -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:
+2
View File
@@ -45,6 +45,8 @@ users via Emby. The primary threat surface when exposed to the public internet:
| 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 |
---
+66 -8
View File
@@ -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);
}
+66 -9
View File
@@ -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);
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+21
View File
@@ -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;
+130
View File
@@ -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;
+260
View File
@@ -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;
+17
View File
@@ -49,6 +49,8 @@ tags:
description: SABnzbd API proxy
- name: Emby
description: Emby/Jellyfin API proxy
- name: Ombi
description: Ombi request management
security:
- CookieAuth: []
@@ -155,6 +157,21 @@ components:
nullable: true
description: Sonarr/Radarr type
example: "series"
ombiLink:
type: string
nullable: true
description: Link to Ombi request or search
example: "https://ombi.example.com/#/request/movie/123"
ombiRequestId:
type: string
nullable: true
description: Ombi request ID (if request exists)
example: "123"
ombiTooltip:
type: string
nullable: true
description: Tooltip text for Ombi icon ("Request" or "Search")
example: "Request"
DashboardPayload:
type: object
+18 -2
View File
@@ -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
@@ -167,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,
@@ -176,7 +183,9 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
moviesMap,
sonarrTagMap,
radarrTagMap,
embyUserMap
embyUserMap,
ombiRetriever,
ombiBaseUrl
});
res.json({
@@ -455,6 +464,11 @@ router.get('/stream', 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 = buildUserDownloads(snapshot, {
username,
usernameSanitized: user.name,
@@ -464,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}`);
+19
View File
@@ -45,6 +45,23 @@ function getRadarrLink(movie) {
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
// Helper to build Ombi request link
function getOmbiLink(requestId, type, ombiBaseUrl) {
if (!requestId || !type || !ombiBaseUrl) return null;
return `${ombiBaseUrl}/#/request/${type}/${requestId}`;
}
// Helper to build Ombi search link
function getOmbiSearchLink(searchId, type, ombiBaseUrl) {
if (!searchId || !type || !ombiBaseUrl) return null;
if (type === 'series') {
return `${ombiBaseUrl}/#/tv/search/${searchId}`;
} else if (type === 'movie') {
return `${ombiBaseUrl}/#/movie/search/${searchId}`;
}
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 +118,8 @@ module.exports = {
getImportIssues,
getSonarrLink,
getRadarrLink,
getOmbiLink,
getOmbiSearchLink,
canBlocklist,
extractEpisode,
gatherEpisodes
+6 -2
View File
@@ -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 }) {
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
+80 -6
View File
@@ -44,6 +44,68 @@ function buildMoviesMapFromRecords(queueRecords, historyRecords) {
return moviesMap;
}
/**
* Matches a download object with Ombi requests and adds Ombi links
* @param {Object} downloadObj - Download object to enhance
* @param {Object} seriesOrMovie - Series or movie object from Sonarr/Radarr
* @param {Object} context - Context containing Ombi retriever and base URL
* @returns {Promise<void>}
*/
async function addOmbiMatching(downloadObj, seriesOrMovie, context) {
const { ombiRetriever, ombiBaseUrl } = context;
if (!ombiRetriever || !ombiBaseUrl || !seriesOrMovie) {
return;
}
try {
let ombiRequest = null;
let searchResult = null;
if (downloadObj.type === 'series') {
// For TV shows, try TVDB ID first, then TMDB ID
const tvdbId = seriesOrMovie.tvdbId;
const tmdbId = seriesOrMovie.tmdbId;
ombiRequest = await ombiRetriever.findTvRequest(tvdbId, tmdbId);
if (!ombiRequest) {
// Fallback to search
searchResult = await ombiRetriever.searchTv(tvdbId, tmdbId);
}
} else if (downloadObj.type === 'movie') {
// For movies, try TMDB ID first, then IMDB ID
const tmdbId = seriesOrMovie.tmdbId;
const imdbId = seriesOrMovie.imdbId;
ombiRequest = await ombiRetriever.findMovieRequest(tmdbId, imdbId);
if (!ombiRequest) {
// Fallback to search
searchResult = await ombiRetriever.searchMovie(tmdbId, imdbId);
}
}
if (ombiRequest) {
// Found existing request
downloadObj.ombiLink = `${ombiBaseUrl}/#/request/${ombiRequest.type}/${ombiRequest.id}`;
downloadObj.ombiRequestId = ombiRequest.id;
downloadObj.ombiTooltip = 'Request';
} else if (searchResult) {
// No request found, but search succeeded
if (downloadObj.type === 'series') {
downloadObj.ombiLink = `${ombiBaseUrl}/#/tv/search/${searchResult.id}`;
} else {
downloadObj.ombiLink = `${ombiBaseUrl}/#/movie/search/${searchResult.id}`;
}
downloadObj.ombiTooltip = 'Search';
}
} catch (error) {
// Silently fail Ombi matching - don't break the download object creation
console.error('[DownloadMatcher] Ombi matching error:', error.message);
}
}
/**
* Determines the status and speed for a SABnzbd slot based on queue state.
* @param {Object} slot - SABnzbd queue slot
@@ -68,7 +130,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 +146,9 @@ function matchSabSlots(slots, context) {
embyUserMap,
queueStatus,
queueSpeed,
queueKbpersec
queueKbpersec,
ombiRetriever,
ombiBaseUrl
} = context;
const matched = [];
@@ -198,6 +262,7 @@ function matchSabSlots(slots, context) {
dlObj.arrContentType = 'episode';
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
await addOmbiMatching(dlObj, series, context);
matched.push(dlObj);
}
}
@@ -250,6 +315,7 @@ function matchSabSlots(slots, context) {
dlObj.arrContentType = 'movie';
}
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
await addOmbiMatching(dlObj, movie, context);
matched.push(dlObj);
}
}
@@ -264,7 +330,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 +341,9 @@ function matchSabHistory(slots, context) {
username,
isAdmin,
showAll,
embyUserMap
embyUserMap,
ombiRetriever,
ombiBaseUrl
} = context;
const matched = [];
@@ -317,6 +385,7 @@ function matchSabHistory(slots, context) {
dlObj.targetPath = series.path || null;
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
}
await addOmbiMatching(dlObj, series, context);
matched.push(dlObj);
}
}
@@ -355,6 +424,7 @@ function matchSabHistory(slots, context) {
dlObj.targetPath = movie.path || null;
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
}
await addOmbiMatching(dlObj, movie, context);
matched.push(dlObj);
}
}
@@ -369,7 +439,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 +452,9 @@ function matchTorrents(torrents, context) {
username,
isAdmin,
showAll,
embyUserMap
embyUserMap,
ombiRetriever,
ombiBaseUrl
} = context;
const matched = [];
@@ -430,6 +502,7 @@ function matchTorrents(torrents, context) {
download.arrContentType = 'episode';
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
await addOmbiMatching(download, series, context);
matched.push(download);
matchedAny = true;
continue;
@@ -474,6 +547,7 @@ function matchTorrents(torrents, context) {
download.arrContentType = 'movie';
}
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
await addOmbiMatching(download, movie, context);
matched.push(download);
matchedAny = true;
continue;
+74 -3
View File
@@ -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;
}
};
+9
View File
@@ -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,