Compare commits

..

20 Commits

Author SHA1 Message Date
gronod ec9b1c6d94 merge branch 'develop' into 'main' - Release v1.7.10
Create Release / release (push) Successful in 38s
Build and Push Docker Image / build (push) Successful in 1m29s
CI / Security audit (push) Successful in 2m12s
CI / Tests & coverage (push) Successful in 2m48s
CI / Swagger Validation & Coverage (push) Successful in 2m37s
2026-05-24 10:23:30 +01:00
gronod 3f8970ea99 chore: bump version to 1.7.10 and update CHANGELOG
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 50s
Build and Push Docker Image / build (push) Successful in 2m21s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m48s
Docs Check / Mermaid diagram parse check (push) Successful in 2m46s
2026-05-24 10:23:22 +01:00
gronod 9491948ec9 fix: resolve Ombi webhook race condition and add Ombi webhook status metrics to status panel
CI / Security audit (push) Successful in 1m45s
Build and Push Docker Image / build (push) Successful in 1m43s
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
2026-05-24 10:22:47 +01:00
gronod d4ee3b8ef7 merge branch 'develop' into 'main' - Release v1.7.9
Create Release / release (push) Successful in 19s
Build and Push Docker Image / build (push) Successful in 1m51s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 2m41s
CI / Tests & coverage (push) Successful in 3m20s
2026-05-23 20:58:20 +01:00
gronod 64c872423f chore: bump version to 1.7.9 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m12s
Docs Check / Markdown lint (push) Successful in 2m34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m14s
CI / Security audit (push) Successful in 3m29s
Docs Check / Mermaid diagram parse check (push) Successful in 3m47s
CI / Swagger Validation & Coverage (push) Successful in 4m12s
CI / Tests & coverage (push) Successful in 4m57s
2026-05-23 20:58:09 +01:00
gronod 86c67bcf29 fix: support PascalCase properties in Ombi webhooks (#42) 2026-05-23 20:57:55 +01:00
gronod 9548eb41f5 merge branch 'develop' into 'main' - Release v1.7.8
Create Release / release (push) Successful in 30s
CI / Security audit (push) Successful in 2m9s
Build and Push Docker Image / build (push) Successful in 2m18s
CI / Swagger Validation & Coverage (push) Successful in 2m36s
CI / Tests & coverage (push) Successful in 3m15s
2026-05-23 20:52:46 +01:00
gronod d1db3118f0 chore: bump version to 1.7.8 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m9s
CI / Security audit (push) Successful in 2m32s
Docs Check / Markdown lint (push) Successful in 2m34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m55s
Docs Check / Mermaid diagram parse check (push) Successful in 4m18s
CI / Tests & coverage (push) Successful in 4m34s
2026-05-23 20:52:27 +01:00
gronod f8aa90011e merge branch 'develop' into 'main' - Release v1.7.7
Create Release / release (push) Successful in 22s
Build and Push Docker Image / build (push) Successful in 2m3s
CI / Security audit (push) Successful in 1m56s
CI / Tests & coverage (push) Successful in 2m28s
CI / Swagger Validation & Coverage (push) Successful in 2m21s
2026-05-23 20:38:14 +01:00
gronod 82b3824658 chore: bump version to 1.7.7 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m17s
Docs Check / Markdown lint (push) Successful in 2m27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m44s
CI / Security audit (push) Successful in 3m10s
Docs Check / Mermaid diagram parse check (push) Successful in 3m54s
CI / Tests & coverage (push) Successful in 4m6s
CI / Swagger Validation & Coverage (push) Successful in 4m23s
2026-05-23 20:38:05 +01:00
gronod 49e3261b59 ci: use RELEASE_TOKEN for Gitea Container Registry authentication
CI / Security audit (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m36s
CI / Tests & coverage (push) Successful in 3m0s
Build and Push Docker Image / build (push) Successful in 2m30s
2026-05-23 19:43:28 +01:00
gronod 2934becf32 ci: publish Docker container image to Gitea Package Registry in parallel
CI / Security audit (push) Successful in 1m58s
CI / Swagger Validation & Coverage (push) Successful in 2m25s
CI / Tests & coverage (push) Successful in 3m0s
Build and Push Docker Image / build (push) Failing after 1m38s
2026-05-23 19:01:38 +01:00
gronod 6ff660b8af merge branch 'develop' into 'main' - Release v1.7.6
Create Release / release (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 1m35s
CI / Tests & coverage (push) Successful in 2m46s
CI / Security audit (push) Successful in 1m41s
CI / Swagger Validation & Coverage (push) Successful in 2m22s
2026-05-23 18:55:18 +01:00
gronod 6ac0a8421e fix: resolve rate-limiting and Ombi requests caching bugs (fixes #42, fixes #43)
Build and Push Docker Image / build (push) Successful in 1m34s
Docs Check / Markdown lint (push) Successful in 2m14s
CI / Security audit (push) Successful in 2m30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
Docs Check / Mermaid diagram parse check (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 3m59s
2026-05-23 18:55:03 +01:00
gronod a021ceba47 merge branch 'develop' into 'main' - Release v1.7.5
Build and Push Docker Image / build (push) Successful in 43s
Create Release / release (push) Successful in 54s
CI / Security audit (push) Successful in 1m48s
CI / Tests & coverage (push) Successful in 2m41s
CI / Swagger Validation & Coverage (push) Successful in 2m30s
2026-05-23 10:13:54 +01:00
gronod f8c7e35f31 chore: bump version to 1.7.5 and update CHANGELOG
Docs Check / Markdown lint (push) Successful in 42s
Docs Check / Mermaid diagram parse check (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 4m17s
Build and Push Docker Image / build (push) Successful in 5m5s
CI / Swagger Validation & Coverage (push) Successful in 5m19s
CI / Tests & coverage (push) Successful in 6m27s
2026-05-23 10:13:25 +01:00
gronod de71580756 fix(ombi): retrieve and include settings ID in webhook enable payload (resolves #41)
Build and Push Docker Image / build (push) Successful in 1m10s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 4m35s
CI / Security audit (push) Successful in 5m8s
CI / Swagger Validation & Coverage (push) Successful in 6m50s
CI / Tests & coverage (push) Successful in 7m25s
2026-05-23 10:12:07 +01:00
gronod 2943afdbaf merge branch 'develop' into 'main' - Release v1.7.4
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 52s
CI / Swagger Validation & Coverage (push) Successful in 2m53s
CI / Security audit (push) Successful in 1m34s
CI / Tests & coverage (push) Successful in 3m21s
2026-05-23 10:00:59 +01:00
gronod 1d571b066d chore: bump version to 1.7.4 and update CHANGELOG
Docs Check / Markdown lint (push) Successful in 46s
Build and Push Docker Image / build (push) Successful in 1m44s
Docs Check / Mermaid diagram parse check (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m28s
CI / Security audit (push) Successful in 2m37s
CI / Swagger Validation & Coverage (push) Successful in 3m9s
CI / Tests & coverage (push) Successful in 3m46s
2026-05-23 10:00:18 +01:00
gronod db809f2fb3 fix(ombi): register ombiRoutes in production server entry point (resolves #40)
Build and Push Docker Image / build (push) Successful in 1m6s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m18s
CI / Security audit (push) Successful in 2m41s
CI / Swagger Validation & Coverage (push) Successful in 3m5s
CI / Tests & coverage (push) Successful in 3m36s
2026-05-23 09:58:42 +01:00
17 changed files with 463 additions and 39 deletions
+20 -3
View File
@@ -23,17 +23,34 @@ jobs:
if [[ "$BRANCH" == develop* ]]; then if [[ "$BRANCH" == develop* ]]; then
# Sanitise branch name for tag: replace slashes with dashes # Sanitise branch name for tag: replace slashes with dashes
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-') SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})" TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building develop image tags: ${TAGS}"
else else
RELEASE_NAME=${BRANCH#release/} RELEASE_NAME=${BRANCH#release/}
# Primary registry tags
TAGS="reg.i3omb.com/sofarr:${VERSION}" TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}" TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest" TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
# Gitea package registry tags
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}" echo "Building release image tags: ${TAGS}"
fi fi
- name: Log into Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.i3omb.com
username: ${{ github.actor }}
password: ${{ secrets.RELEASE_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
+57
View File
@@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.10] - 2026-05-24
### Fixed
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
---
## [1.7.9] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.8] - 2026-05-23
### Fixed
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.7] - 2026-05-23
### Fixed
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
---
## [1.7.6] - 2026-05-23
### Fixed
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
---
## [1.7.5] - 2026-05-23
### Fixed
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
---
## [1.7.4] - 2026-05-23
### Fixed
- **Ombi webhook registration in production** — Fixed a bug where `/api/ombi/webhook/enable` and other `/api/ombi/*` endpoints returned `404 (Not Found)` in production. The core Express app factory (`server/app.js`) registered the router, but the production server entry point (`server/index.js`) duplicated Express setup instead of utilizing the factory, omitting the `ombiRoutes` registration entirely. Re-registered the router in `server/index.js`, resolves Gitea Issue [#40](https://git.i3omb.com/Gandalf/sofarr/issues/40).
--- ---
## [1.7.3] - 2026-05-23 ## [1.7.3] - 2026-05-23
+6 -2
View File
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
const wh = data.webhooks; const wh = data.webhooks;
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○'; const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
const radarrEnabled = wh.radarr?.enabled ? '●' : '○'; const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
const sonarrEvents = wh.sonarr?.eventsReceived || 0; const sonarrEvents = wh.sonarr?.eventsReceived || 0;
const radarrEvents = wh.radarr?.eventsReceived || 0; const radarrEvents = wh.radarr?.eventsReceived || 0;
const ombiEvents = wh.ombi?.eventsReceived || 0;
const sonarrPolls = wh.sonarr?.pollsSkipped || 0; const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
const radarrPolls = wh.radarr?.pollsSkipped || 0; const radarrPolls = wh.radarr?.pollsSkipped || 0;
const ombiPolls = wh.ombi?.pollsSkipped || 0;
html += ` html += `
<div class="status-card"> <div class="status-card">
<div class="status-card-title">Webhooks</div> <div class="status-card-title">Webhooks</div>
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div> <div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div> <div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div> <div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div> <div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
</div>`; </div>`;
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.3", "version": "1.7.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.3", "version": "1.7.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.3", "version": "1.7.10",
"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", "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", "main": "server/index.js",
"scripts": { "scripts": {
+1
View File
@@ -96,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300, max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' } message: { error: 'Too many requests, please try again later' }
}); });
+9 -6
View File
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
/** /**
* Refresh cached data from Ombi API * Refresh cached data from Ombi API
* @param {boolean} force - Whether to force a refresh regardless of TTL
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async refreshCache() { async refreshCache(force = false) {
if (!this.isCacheExpired()) { if (!force && !this.isCacheExpired()) {
return; return;
} }
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
/** /**
* Get all movie requests * Get all movie requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of movie request objects * @returns {Promise<Array>} Array of movie request objects
*/ */
async getMovieRequests() { async getMovieRequests(force = false) {
await this.refreshCache(); await this.refreshCache(force);
return this.cache.movieRequests; return this.cache.movieRequests;
} }
/** /**
* Get all TV requests * Get all TV requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of TV request objects * @returns {Promise<Array>} Array of TV request objects
*/ */
async getTvRequests() { async getTvRequests(force = false) {
await this.refreshCache(); await this.refreshCache(force);
return this.cache.tvRequests; return this.cache.tvRequests;
} }
+3
View File
@@ -89,6 +89,7 @@ const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history'); const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook'); const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const verifyCsrf = require('./middleware/verifyCsrf'); const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'); const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config'); const { validateInstanceUrl } = require('./utils/config');
@@ -205,6 +206,7 @@ const apiLimiter = rateLimit({
max: 300, // 300 requests per IP per window (generous for polling) max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' } message: { error: 'Too many requests, please try again later' }
}); });
@@ -372,6 +374,7 @@ app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes); app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes); app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes); app.use('/api/emby', embyRoutes);
app.use('/api/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes); app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes); app.use('/api/history', historyRoutes);
+19 -1
View File
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
// initialize() is idempotent - cheap no-op if already initialized // initialize() is idempotent - cheap no-op if already initialized
await arrRetrieverRegistry.initialize(); await arrRetrieverRegistry.initialize();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(); const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
// Filter by user if not admin or if showAll is false // Filter by user if not admin or if showAll is false
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll); const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
@@ -225,9 +225,27 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
// Call Ombi API to register webhook // Call Ombi API to register webhook
const axios = require('axios'); const axios = require('axios');
// Get existing settings to retrieve the database ID
const currentRes = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
).catch(err => {
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
return { data: {} };
});
const currentConfig = currentRes.data || {};
const settingsId = currentConfig.id || 0;
const response = await axios.post( const response = await axios.post(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`, `${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{ {
id: settingsId,
enabled: true, enabled: true,
webhookUrl: webhookUrl, webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey applicationToken: ombiInst.apiKey
+12 -4
View File
@@ -4,9 +4,9 @@ const router = express.Router();
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const cache = require('../utils/cache'); const cache = require('../utils/cache');
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller'); const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config'); const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const { getGlobalWebhookMetrics } = require('../utils/cache'); const { getGlobalWebhookMetrics } = require('../utils/cache');
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus'); const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
/** /**
* @openapi * @openapi
@@ -121,6 +121,7 @@ router.get('/', requireAuth, async (req, res) => {
// Check webhook configuration for each service // Check webhook configuration for each service
const sonarrInstances = getSonarrInstances(); const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances(); const radarrInstances = getRadarrInstances();
const ombiInstances = getOmbiInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0 const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr') ? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
@@ -128,15 +129,21 @@ router.get('/', requireAuth, async (req, res) => {
const radarrWebhookConfigured = radarrInstances.length > 0 const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr') ? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false; : false;
const ombiWebhookConfigured = ombiInstances.length > 0
? await checkOmbiWebhookConfigured(ombiInstances[0])
: false;
// Find Sonarr and Radarr metrics from instances // Find Sonarr, Radarr, and Ombi metrics from instances
const sonarrMetrics = {}; const sonarrMetrics = {};
const radarrMetrics = {}; const radarrMetrics = {};
const ombiMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) { for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) { if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics; sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) { } else if (url.includes('radarr')) {
radarrMetrics[url] = metrics; radarrMetrics[url] = metrics;
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
ombiMetrics[url] = metrics;
} }
} }
@@ -156,7 +163,8 @@ router.get('/', requireAuth, async (req, res) => {
cache: cacheStats, cache: cacheStats,
webhooks: { webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured), sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured) radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
} }
}); });
} catch (err) { } catch (err) {
+12 -6
View File
@@ -87,7 +87,7 @@ const VALID_EVENT_TYPES = new Set([
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete', 'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored', 'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
// Ombi notification types // Ombi notification types
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing' 'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
]); ]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys. // Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
@@ -135,6 +135,7 @@ const HISTORY_EVENTS = new Set([
// Ombi event types — all Ombi events refresh the requests cache // Ombi event types — all Ombi events refresh the requests cache
const OMBI_EVENTS = new Set([ const OMBI_EVENTS = new Set([
'NewRequest',
'RequestAvailable', 'RequestAvailable',
'RequestApproved', 'RequestApproved',
'RequestDeclined', 'RequestDeclined',
@@ -258,7 +259,9 @@ async function processWebhookEvent(serviceType, eventType) {
const ombiInstances = getOmbiInstances(); const ombiInstances = getOmbiInstances();
if (affectsOmbi) { if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(); // Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
await new Promise(r => setTimeout(r, 2000));
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL); cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`); logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
} }
@@ -716,9 +719,12 @@ router.post('/ombi', webhookLimiter, (req, res) => {
return res.status(401).json({ error: 'Unauthorized' }); return res.status(401).json({ error: 'Unauthorized' });
} }
// Ombi uses notificationType instead of eventType // Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
const { notificationType, requestId, requestedUser, applicationUrl } = req.body; const notificationType = req.body.notificationType || req.body.NotificationType;
const eventType = notificationType || req.body.eventType; const requestId = req.body.requestId || req.body.RequestId;
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
const eventType = notificationType || req.body.eventType || req.body.EventType;
// Extract username from requestedUser (handles both object and string formats) // Extract username from requestedUser (handles both object and string formats)
const username = extractRequestedUser(req.body); const username = extractRequestedUser(req.body);
@@ -731,7 +737,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
// Use applicationUrl as instance identifier for replay protection // Use applicationUrl as instance identifier for replay protection
const instanceName = applicationUrl || 'ombi'; const instanceName = applicationUrl || 'ombi';
// Use requestId + eventType + current time as replay key // Use requestId + eventType + current time as replay key
const eventDate = req.body.requestedDate || new Date().toISOString(); const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) { if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`); logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
+19
View File
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
}; };
} }
/**
* Check if Sofarr webhook is configured in an Ombi instance.
* @param {Object} instance - The Ombi instance config
* @returns {Promise<boolean>} true if webhook is configured
*/
async function checkOmbiWebhookConfigured(instance) {
try {
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
headers: { 'ApiKey': instance.apiKey },
timeout: 5000
});
return !!(response.data && response.data.enabled);
} catch (err) {
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
return false;
}
}
module.exports = { module.exports = {
checkWebhookConfigured, checkWebhookConfigured,
checkOmbiWebhookConfigured,
aggregateMetrics aggregateMetrics
}; };
+7 -5
View File
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
/** /**
* Get all Ombi requests * Get all Ombi requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Object with movie and TV request arrays * @returns {Promise<Object>} Object with movie and TV request arrays
*/ */
async getOmbiRequests() { async getOmbiRequests(force = false) {
const ombiRetrievers = this.getOmbiRetrievers(); const ombiRetrievers = this.getOmbiRetrievers();
if (ombiRetrievers.length === 0) { if (ombiRetrievers.length === 0) {
return { movie: [], tv: [] }; return { movie: [], tv: [] };
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
// Use the first Ombi retriever (single instance expected) // Use the first Ombi retriever (single instance expected)
const retriever = ombiRetrievers[0]; const retriever = ombiRetrievers[0];
try { try {
const movieRequests = await retriever.getMovieRequests(); const movieRequests = await retriever.getMovieRequests(force);
const tvRequests = await retriever.getTvRequests(); const tvRequests = await retriever.getTvRequests(false);
return { movie: movieRequests, tv: tvRequests }; return { movie: movieRequests, tv: tvRequests };
} catch (error) { } catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`); logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
/** /**
* Get Ombi requests grouped by type * Get Ombi requests grouped by type
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Requests grouped by type (movie, tv) * @returns {Promise<Object>} Requests grouped by type (movie, tv)
*/ */
async getOmbiRequestsByType() { async getOmbiRequestsByType(force = false) {
return await this.getOmbiRequests(); return await this.getOmbiRequests(force);
}, },
/** /**
+9 -7
View File
@@ -15,17 +15,19 @@
function extractRequestedUser(request) { function extractRequestedUser(request) {
if (!request) return ''; if (!request) return '';
const requestedUser = request.requestedUser || request.RequestedUser;
// Handle object format: OmbiStore.Entities.OmbiUser // Handle object format: OmbiStore.Entities.OmbiUser
if (request.requestedUser && typeof request.requestedUser === 'object') { if (requestedUser && typeof requestedUser === 'object') {
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias // Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
return request.requestedUser.alias || return requestedUser.alias || requestedUser.Alias ||
request.requestedUser.userAlias || requestedUser.userAlias || requestedUser.UserAlias ||
request.requestedUser.userName || requestedUser.userName || requestedUser.UserName ||
request.requestedUser.normalizedUserName || requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
request.requestedByAlias || ''; request.requestedByAlias || request.RequestedByAlias || '';
} }
// Handle string format (fallback for compatibility) // Handle string format (fallback for compatibility)
return request.requestedUser || request.requestedByAlias || ''; return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
} }
function filterRequestsByUser(requests, username, showAll) { function filterRequestsByUser(requests, username, showAll) {
+36 -1
View File
@@ -850,7 +850,15 @@ describe('POST /api/ombi/webhook/enable', () => {
it('enables webhook successfully', async () => { it('enables webhook successfully', async () => {
nock(OMBI_BASE) nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook') .get('/api/v1/Settings/notifications/webhook')
.reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null });
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook', {
id: 42,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true }); .reply(200, { success: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
@@ -866,7 +874,34 @@ describe('POST /api/ombi/webhook/enable', () => {
expect(res.body.applicationToken).toBe('test-ombi-key'); expect(res.body.applicationToken).toBe('test-ombi-key');
}); });
it('enables webhook successfully even if GET settings fails', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Failed to fetch settings' });
nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook', {
id: 0,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true });
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
const res = await request(app)
.post('/api/ombi/webhook/enable')
.set('Cookie', cookies)
.set('X-CSRF-Token', csrfToken)
.expect(200);
expect(res.body.success).toBe(true);
});
it('handles Ombi API errors gracefully', async () => { it('handles Ombi API errors gracefully', async () => {
nock(OMBI_BASE)
.get('/api/v1/Settings/notifications/webhook')
.reply(200, { id: 42 });
nock(OMBI_BASE) nock(OMBI_BASE)
.post('/api/v1/Settings/notifications/webhook') .post('/api/v1/Settings/notifications/webhook')
.reply(500, { error: 'Internal server error' }); .reply(500, { error: 'Internal server error' });
+159 -1
View File
@@ -97,6 +97,9 @@ function makeApp() {
process.env.RADARR_INSTANCES = JSON.stringify([ process.env.RADARR_INSTANCES = JSON.stringify([
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' } { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
]); ]);
process.env.OMBI_INSTANCES = JSON.stringify([
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
]);
return createApp({ skipRateLimits: true }); return createApp({ skipRateLimits: true });
} }
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
} }
beforeEach(() => { beforeEach(() => {
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget) // Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] }); nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] }); nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
}); });
afterEach(() => { afterEach(() => {
@@ -125,6 +129,7 @@ afterEach(() => {
delete process.env.SOFARR_BASE_URL; delete process.env.SOFARR_BASE_URL;
delete process.env.SONARR_INSTANCES; delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES; delete process.env.RADARR_INSTANCES;
delete process.env.OMBI_INSTANCES;
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -518,3 +523,156 @@ describe('GET /api/webhook/config', () => {
expect(res.body.missing).toHaveLength(2); expect(res.body.missing).toHaveLength(2);
}); });
}); });
// ---------------------------------------------------------------------------
// Ombi webhook receiver
// ---------------------------------------------------------------------------
describe('POST /api/webhook/ombi', () => {
function postOmbi(app, payload, secret = VALID_SECRET) {
const req = request(app).post('/api/webhook/ombi').send(payload);
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
return req;
}
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
const app = makeApp();
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
const app = makeApp();
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 400 when notificationType is missing or invalid', async () => {
const app = makeApp();
const res = await postOmbi(app, { requestId: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid or missing notificationType');
});
it('returns 400 when notificationType is unknown', async () => {
const app = makeApp();
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid or missing notificationType');
});
it('returns 200 { received: true } for a valid NewRequest event', async () => {
const app = makeApp();
// Nock requests endpoint since processWebhookEvent will fetch requests
nock('https://ombi.test')
.get('/api/v1/Request/movie')
.reply(200, []);
nock('https://ombi.test')
.get('/api/v1/Request/tv')
.reply(200, []);
const payload = {
notificationType: 'NewRequest',
requestId: 123,
requestedUser: 'gordon',
title: 'New Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:30:00.000Z'
};
const res = await postOmbi(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
expect(res.body.duplicate).toBeUndefined();
});
it('returns 200 { received: true } for a valid RequestAvailable event', async () => {
const app = makeApp();
nock('https://ombi.test')
.get('/api/v1/Request/movie')
.reply(200, []);
nock('https://ombi.test')
.get('/api/v1/Request/tv')
.reply(200, []);
const payload = {
notificationType: 'RequestAvailable',
requestId: 124,
requestedUser: 'gordon',
title: 'Available Movie',
type: 'Movie',
requestStatus: 'Available',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:31:00.000Z'
};
const res = await postOmbi(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns duplicate: true for a replay of the same event', async () => {
const app = makeApp();
nock('https://ombi.test').persist()
.get('/api/v1/Request/movie')
.reply(200, []);
nock('https://ombi.test').persist()
.get('/api/v1/Request/tv')
.reply(200, []);
const payload = {
notificationType: 'NewRequest',
requestId: 125,
requestedUser: 'gordon',
title: 'New Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:32:00.000Z'
};
// First request
const res1 = await postOmbi(app, payload);
expect(res1.status).toBe(200);
expect(res1.body.duplicate).toBeUndefined();
// Replay
const res2 = await postOmbi(app, payload);
expect(res2.status).toBe(200);
expect(res2.body.duplicate).toBe(true);
});
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', async () => {
const app = makeApp();
nock('https://ombi.test')
.get('/api/v1/Request/movie')
.reply(200, []);
nock('https://ombi.test')
.get('/api/v1/Request/tv')
.reply(200, []);
const payload = {
NotificationType: 'NewRequest',
RequestId: 126,
RequestedUser: { UserName: 'gordon_pascal' },
Title: 'Pascal Movie',
Type: 'Movie',
RequestStatus: 'Pending',
ApplicationUrl: 'https://ombi.test',
RequestedDate: '2026-05-23T20:33:00.000Z'
};
const res = await postOmbi(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
expect(res.body.duplicate).toBeUndefined();
});
});
+91
View File
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
expect(retriever.cache.movieRequests).toHaveLength(2); expect(retriever.cache.movieRequests).toHaveLength(2);
}); });
it('should refresh if cache is not expired but force is true', async () => {
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
const mockTvShows = [];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies1);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const retriever = new OmbiRetriever(instanceConfig);
// First refresh
await retriever.refreshCache();
expect(retriever.cache.movieRequests).toHaveLength(1);
// Set up new mocks for second refresh without advancing time
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies2);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
// Second refresh with force=true should make API calls
await retriever.refreshCache(true);
expect(retriever.cache.movieRequests).toHaveLength(2);
});
it('should build movie map with TMDB and IMDB IDs', async () => { it('should build movie map with TMDB and IMDB IDs', async () => {
const mockMovies = [ const mockMovies = [
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' }, { id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockMovies); expect(result).toEqual(mockMovies);
}); });
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
const mockTvShows = [];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies1);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const retriever = new OmbiRetriever(instanceConfig);
await retriever.refreshCache();
// Set up new mocks for second fetch
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies2);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows);
const result = await retriever.getMovieRequests(true);
expect(result).toEqual(mockMovies2);
});
}); });
describe('getTvRequests', () => { describe('getTvRequests', () => {
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockTvShows); expect(result).toEqual(mockTvShows);
}); });
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
const mockMovies = [];
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows1);
const retriever = new OmbiRetriever(instanceConfig);
await retriever.refreshCache();
// Set up new mocks for second fetch
nock(baseUrl)
.get('/api/v1/Request/movie')
.reply(200, mockMovies);
nock(baseUrl)
.get('/api/v1/Request/tv')
.reply(200, mockTvShows2);
const result = await retriever.getTvRequests(true);
expect(result).toEqual(mockTvShows2);
});
}); });
describe('findMovieRequest', () => { describe('findMovieRequest', () => {