Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 | |||
| d4ee3b8ef7 | |||
| 64c872423f | |||
| 86c67bcf29 | |||
| 9548eb41f5 | |||
| d1db3118f0 | |||
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e | |||
| a021ceba47 | |||
| f8c7e35f31 | |||
| de71580756 |
@@ -23,17 +23,34 @@ jobs:
|
||||
if [[ "$BRANCH" == develop* ]]; then
|
||||
# Sanitise branch name for tag: replace slashes with dashes
|
||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
||||
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image tags: ${TAGS}"
|
||||
else
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
|
||||
# Primary registry tags
|
||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||
|
||||
# Gitea package registry tags
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
||||
echo "Building release image tags: ${TAGS}"
|
||||
fi
|
||||
|
||||
- name: Log into Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.i3omb.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
@@ -4,6 +4,65 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.11] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.10] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.9] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.8] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.7] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.6] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.5] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.4] - 2026-05-23
|
||||
|
||||
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
|
||||
@@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType,
|
||||
isAdmin: state.isAdmin,
|
||||
canBlocklist: download.canBlocklist
|
||||
|
||||
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const ombiEvents = wh.ombi?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
const ombiPolls = wh.ombi?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.11",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.11",
|
||||
"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": {
|
||||
|
||||
@@ -96,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
/**
|
||||
* Refresh cached data from Ombi API
|
||||
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshCache() {
|
||||
if (!this.isCacheExpired()) {
|
||||
async refreshCache(force = false) {
|
||||
if (!force && !this.isCacheExpired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
/**
|
||||
* Get all movie requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of movie request objects
|
||||
*/
|
||||
async getMovieRequests() {
|
||||
await this.refreshCache();
|
||||
async getMovieRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.movieRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of TV request objects
|
||||
*/
|
||||
async getTvRequests() {
|
||||
await this.refreshCache();
|
||||
async getTvRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.tvRequests;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ const apiLimiter = rateLimit({
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
+10
-1
@@ -276,7 +276,6 @@ components:
|
||||
- arrQueueId
|
||||
- arrType
|
||||
- arrInstanceUrl
|
||||
- arrContentId
|
||||
- arrContentType
|
||||
properties:
|
||||
arrQueueId:
|
||||
@@ -301,6 +300,16 @@ components:
|
||||
type: integer
|
||||
description: episodeId (Sonarr) or movieId (Radarr)
|
||||
example: 456
|
||||
arrContentIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Array of episodeIds for multi-episode packs (Sonarr)
|
||||
example: [456, 457]
|
||||
arrSeriesId:
|
||||
type: integer
|
||||
description: seriesId for fallback automatic series search (Sonarr)
|
||||
example: 789
|
||||
arrContentType:
|
||||
type: string
|
||||
enum: [episode, movie]
|
||||
|
||||
@@ -676,10 +676,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
|
||||
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||
@@ -724,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
// Step 2: Trigger a new automatic search
|
||||
let commandBody;
|
||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
||||
if (arrContentId) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
|
||||
} else if (arrSeriesId) {
|
||||
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
|
||||
}
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
|
||||
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||
}
|
||||
|
||||
@@ -737,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
const { pollAllServices } = require('../utils/poller');
|
||||
pollAllServices().catch(() => {});
|
||||
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||
|
||||
+19
-1
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
|
||||
// initialize() is idempotent - cheap no-op if already initialized
|
||||
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
|
||||
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
|
||||
const axios = require('axios');
|
||||
|
||||
// Get existing settings to retrieve the database ID
|
||||
const currentRes = await axios.get(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
headers: {
|
||||
'ApiKey': ombiInst.apiKey
|
||||
}
|
||||
}
|
||||
).catch(err => {
|
||||
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
const currentConfig = currentRes.data || {};
|
||||
const settingsId = currentConfig.id || 0;
|
||||
|
||||
const response = await axios.post(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
id: settingsId,
|
||||
enabled: true,
|
||||
webhookUrl: webhookUrl,
|
||||
applicationToken: ombiInst.apiKey
|
||||
|
||||
+12
-4
@@ -4,9 +4,9 @@ const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -121,6 +121,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
// Check webhook configuration for each service
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||
@@ -128,15 +129,21 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||
: false;
|
||||
const ombiWebhookConfigured = ombiInstances.length > 0
|
||||
? await checkOmbiWebhookConfigured(ombiInstances[0])
|
||||
: false;
|
||||
|
||||
// Find Sonarr and Radarr metrics from instances
|
||||
// Find Sonarr, Radarr, and Ombi metrics from instances
|
||||
const sonarrMetrics = {};
|
||||
const radarrMetrics = {};
|
||||
const ombiMetrics = {};
|
||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||
if (url.includes('sonarr')) {
|
||||
sonarrMetrics[url] = metrics;
|
||||
} else if (url.includes('radarr')) {
|
||||
radarrMetrics[url] = metrics;
|
||||
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
|
||||
ombiMetrics[url] = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +163,8 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
cache: cacheStats,
|
||||
webhooks: {
|
||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -87,7 +87,7 @@ const VALID_EVENT_TYPES = new Set([
|
||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||
// Ombi notification types
|
||||
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||
]);
|
||||
|
||||
// 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
|
||||
const OMBI_EVENTS = new Set([
|
||||
'NewRequest',
|
||||
'RequestAvailable',
|
||||
'RequestApproved',
|
||||
'RequestDeclined',
|
||||
@@ -258,7 +259,9 @@ async function processWebhookEvent(serviceType, eventType) {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
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);
|
||||
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' });
|
||||
}
|
||||
|
||||
// Ombi uses notificationType instead of eventType
|
||||
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
|
||||
const eventType = notificationType || req.body.eventType;
|
||||
// Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
|
||||
const notificationType = req.body.notificationType || req.body.NotificationType;
|
||||
const requestId = req.body.requestId || req.body.RequestId;
|
||||
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
||||
|
||||
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
||||
|
||||
// Extract username from requestedUser (handles both object and string formats)
|
||||
const username = extractRequestedUser(req.body);
|
||||
@@ -731,7 +737,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
||||
// Use applicationUrl as instance identifier for replay protection
|
||||
const instanceName = applicationUrl || 'ombi';
|
||||
// Use requestId + eventType + current time as replay key
|
||||
const eventDate = req.body.requestedDate || new Date().toISOString();
|
||||
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
||||
|
||||
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
||||
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
||||
|
||||
@@ -209,6 +209,8 @@ async function matchSabSlots(slots, context) {
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
@@ -451,6 +453,8 @@ async function matchTorrents(torrents, context) {
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
|
||||
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||
* @param {Object} instance - The Ombi instance config
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkOmbiWebhookConfigured(instance) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||
headers: { 'ApiKey': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
return !!(response.data && response.data.enabled);
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebhookConfigured,
|
||||
checkOmbiWebhookConfigured,
|
||||
aggregateMetrics
|
||||
};
|
||||
|
||||
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
|
||||
|
||||
/**
|
||||
* Get all Ombi requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||
*/
|
||||
async getOmbiRequests() {
|
||||
async getOmbiRequests(force = false) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return { movie: [], tv: [] };
|
||||
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
|
||||
// Use the first Ombi retriever (single instance expected)
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
const movieRequests = await retriever.getMovieRequests();
|
||||
const tvRequests = await retriever.getTvRequests();
|
||||
const movieRequests = await retriever.getMovieRequests(force);
|
||||
const tvRequests = await retriever.getTvRequests(false);
|
||||
return { movie: movieRequests, tv: tvRequests };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
|
||||
|
||||
/**
|
||||
* Get Ombi requests grouped by type
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||
*/
|
||||
async getOmbiRequestsByType() {
|
||||
return await this.getOmbiRequests();
|
||||
async getOmbiRequestsByType(force = false) {
|
||||
return await this.getOmbiRequests(force);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,17 +15,19 @@
|
||||
function extractRequestedUser(request) {
|
||||
if (!request) return '';
|
||||
|
||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
||||
|
||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
||||
if (requestedUser && typeof requestedUser === 'object') {
|
||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||
return request.requestedUser.alias ||
|
||||
request.requestedUser.userAlias ||
|
||||
request.requestedUser.userName ||
|
||||
request.requestedUser.normalizedUserName ||
|
||||
request.requestedByAlias || '';
|
||||
return requestedUser.alias || requestedUser.Alias ||
|
||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
||||
requestedUser.userName || requestedUser.UserName ||
|
||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
||||
request.requestedByAlias || request.RequestedByAlias || '';
|
||||
}
|
||||
// Handle string format (fallback for compatibility)
|
||||
return request.requestedUser || request.requestedByAlias || '';
|
||||
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
||||
}
|
||||
|
||||
function filterRequestsByUser(requests, username, showAll) {
|
||||
|
||||
@@ -918,6 +918,60 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
expect(res.status).toBe(502);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -850,7 +850,15 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
|
||||
it('enables webhook successfully', async () => {
|
||||
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 });
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
nock(OMBI_BASE)
|
||||
.get('/api/v1/Settings/notifications/webhook')
|
||||
.reply(200, { id: 42 });
|
||||
nock(OMBI_BASE)
|
||||
.post('/api/v1/Settings/notifications/webhook')
|
||||
.reply(500, { error: 'Internal server error' });
|
||||
|
||||
@@ -97,6 +97,9 @@ function makeApp() {
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||
]);
|
||||
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
||||
]);
|
||||
return createApp({ skipRateLimits: true });
|
||||
}
|
||||
|
||||
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
||||
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -125,6 +129,7 @@ afterEach(() => {
|
||||
delete process.env.SOFARR_BASE_URL;
|
||||
delete process.env.SONARR_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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should refresh if cache is not expired but force is true', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// First refresh
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
|
||||
// Set up new mocks for second refresh without advancing time
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
// Second refresh with force=true should make API calls
|
||||
await retriever.refreshCache(true);
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user