Compare commits

...

11 Commits

Author SHA1 Message Date
gronod 9c7dcf55b0 merge branch 'develop' into 'main' - Release v1.7.11
Build and Push Docker Image / build (push) Successful in 56s
Create Release / release (push) Successful in 48s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
CI / Security audit (push) Successful in 2m31s
CI / Tests & coverage (push) Successful in 3m24s
2026-05-24 10:49:01 +01:00
gronod afc940aba7 chore: bump version to 1.7.11 and update CHANGELOG
Build and Push Docker Image / build (push) Successful in 2m2s
Docs Check / Markdown lint (push) Successful in 1m10s
Docs Check / Mermaid diagram parse check (push) Successful in 1m39s
CI / Security audit (push) Successful in 3m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m31s
CI / Swagger Validation & Coverage (push) Successful in 4m24s
CI / Tests & coverage (push) Successful in 4m39s
2026-05-24 10:48:52 +01:00
gronod 5488969387 fix: resolve blocklist & search failures on Sonarr season packs and multi-episode releases
Build and Push Docker Image / build (push) Successful in 1m31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m42s
CI / Security audit (push) Successful in 3m26s
CI / Swagger Validation & Coverage (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 4m19s
2026-05-24 10:42:54 +01:00
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
15 changed files with 204 additions and 27 deletions
+35
View File
@@ -4,6 +4,41 @@ 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.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 ## [1.7.7] - 2026-05-23
### Fixed ### Fixed
+2
View File
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
arrInstanceUrl: download.arrInstanceUrl, arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey, arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId, arrContentId: download.arrContentId,
arrContentIds: download.arrContentIds,
arrSeriesId: download.arrSeriesId,
arrContentType: download.arrContentType arrContentType: download.arrContentType
}) })
}); });
+2
View File
@@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
arrInstanceUrl: download.arrInstanceUrl, arrInstanceUrl: download.arrInstanceUrl,
arrInstanceKey: download.arrInstanceKey, arrInstanceKey: download.arrInstanceKey,
arrContentId: download.arrContentId, arrContentId: download.arrContentId,
arrContentIds: download.arrContentIds,
arrSeriesId: download.arrSeriesId,
arrContentType: download.arrContentType, arrContentType: download.arrContentType,
isAdmin: state.isAdmin, isAdmin: state.isAdmin,
canBlocklist: download.canBlocklist canBlocklist: download.canBlocklist
+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.7", "version": "1.7.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.7", "version": "1.7.11",
"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.7", "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", "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": {
+10 -1
View File
@@ -276,7 +276,6 @@ components:
- arrQueueId - arrQueueId
- arrType - arrType
- arrInstanceUrl - arrInstanceUrl
- arrContentId
- arrContentType - arrContentType
properties: properties:
arrQueueId: arrQueueId:
@@ -301,6 +300,16 @@ components:
type: integer type: integer
description: episodeId (Sonarr) or movieId (Radarr) description: episodeId (Sonarr) or movieId (Radarr)
example: 456 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: arrContentType:
type: string type: string
enum: [episode, movie] enum: [episode, movie]
+12 -6
View File
@@ -676,10 +676,10 @@ router.get('/stream', requireAuth, async (req, res) => {
router.post('/blocklist-search', requireAuth, async (req, res) => { router.post('/blocklist-search', requireAuth, async (req, res) => {
try { try {
const user = req.user; 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) { if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType }); console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
} }
if (arrType !== 'sonarr' && arrType !== 'radarr') { if (arrType !== 'sonarr' && arrType !== 'radarr') {
@@ -724,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
// Step 2: Trigger a new automatic search // Step 2: Trigger a new automatic search
let commandBody; let commandBody;
if (arrType === 'sonarr' && arrContentType === 'episode') { if (arrType === 'sonarr' && arrContentType === 'episode') {
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; if (arrContentId) {
} else if (arrType === 'radarr' && arrContentType === 'movie') { 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] }; commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
} }
@@ -737,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
const { pollAllServices } = require('../utils/poller'); const { pollAllServices } = require('../utils/poller');
pollAllServices().catch(() => {}); 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
console.error('[Dashboard] blocklist-search error:', sanitizeError(err)); console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
+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) {
+9 -4
View File
@@ -259,6 +259,8 @@ async function processWebhookEvent(serviceType, eventType) {
const ombiInstances = getOmbiInstances(); const ombiInstances = getOmbiInstances();
if (affectsOmbi) { if (affectsOmbi) {
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
await new Promise(r => setTimeout(r, 2000));
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); 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)`);
@@ -717,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);
@@ -732,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}`);
+4
View File
@@ -209,6 +209,8 @@ async function matchSabSlots(slots, context) {
dlObj.arrType = 'sonarr'; dlObj.arrType = 'sonarr';
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentId = sonarrMatch.episodeId || null;
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
dlObj.arrContentType = 'episode'; dlObj.arrContentType = 'episode';
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
@@ -451,6 +453,8 @@ async function matchTorrents(torrents, context) {
download.arrType = 'sonarr'; download.arrType = 'sonarr';
download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
download.arrContentId = sonarrMatch.episodeId || null; download.arrContentId = sonarrMatch.episodeId || null;
download.arrContentIds = sonarrMatch.episodeIds || null;
download.arrSeriesId = sonarrMatch.seriesId || null;
download.arrContentType = 'episode'; download.arrContentType = 'episode';
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
+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
}; };
+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) {
+54
View File
@@ -918,6 +918,60 @@ describe('POST /api/dashboard/blocklist-search', () => {
expect(res.status).toBe(502); expect(res.status).toBe(502);
mockGetAllDownloads.mockRestore(); 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();
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+27
View File
@@ -647,5 +647,32 @@ describe('POST /api/webhook/ombi', () => {
expect(res2.status).toBe(200); expect(res2.status).toBe(200);
expect(res2.body.duplicate).toBe(true); 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();
});
}); });