Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1535a5725a | |||
| 95bd703b26 | |||
| 8fb00843ef | |||
| d2ac7731ca | |||
| 6f6aa5b967 | |||
| 5390bbf615 | |||
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 |
+10
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
|||||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||||
SOFARR_BASE_URL=https://your-sofarr-url
|
SOFARR_BASE_URL=https://your-sofarr-url
|
||||||
|
|
||||||
|
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
|
||||||
|
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
|
||||||
|
# Useful if those services reside in the same local network/docker container setup and
|
||||||
|
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
|
||||||
|
# Example: http://sofarr:3001 or http://192.168.1.50:3001
|
||||||
|
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
|
||||||
|
|
||||||
# --- Webhook Polling Optimization (Phase 5) ---
|
# --- Webhook Polling Optimization (Phase 5) ---
|
||||||
|
|
||||||
# Minutes of silence after which the poller falls back to a full poll
|
# Minutes of silence after which the poller falls back to a full poll
|
||||||
@@ -162,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
OMBI_URL=https://ombi.example.com
|
OMBI_URL=https://ombi.example.com
|
||||||
OMBI_API_KEY=your-ombi-api-key-here
|
OMBI_API_KEY=your-ombi-api-key-here
|
||||||
|
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
|
||||||
|
# to resolve the race condition where Ombi fires the webhook before committing to its database.
|
||||||
|
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOTES
|
# NOTES
|
||||||
|
|||||||
+2
-1
@@ -11,4 +11,5 @@ data/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.agents/
|
.agents/
|
||||||
.windsurf/
|
.windsurf/
|
||||||
|
scratch/
|
||||||
@@ -4,6 +4,86 @@ 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.22] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
|
||||||
|
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
|
||||||
|
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
|
||||||
|
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
|
||||||
|
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
|
||||||
|
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
|
||||||
|
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
|
||||||
|
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.20] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.19] - 2026-05-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
|
||||||
|
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.18] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.17] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.16] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.15] - 2026-05-24
|
## [1.7.15] - 2026-05-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+91
-13
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.user || request.User ||
|
||||||
return request.requestedUser.alias ||
|
request.requestedBy || request.RequestedBy ||
|
||||||
request.requestedUser.userAlias ||
|
request.ombiUser || request.OmbiUser ||
|
||||||
request.requestedUser.userName ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
request.requestedUser.normalizedUserName ||
|
|
||||||
request.requestedByAlias || '';
|
// If userSource is an object, extract key fields
|
||||||
|
if (userSource && typeof userSource === 'object') {
|
||||||
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
// If userSource is a string
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRequests() {
|
export function renderRequests() {
|
||||||
@@ -111,11 +146,38 @@ function createRequestCard(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const username = extractRequestedUser(request);
|
const username = extractRequestedUser(request);
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = document.createElement('span');
|
|
||||||
user.className = 'request-user';
|
|
||||||
user.textContent = `Requested by: ${username}`;
|
user.textContent = `Requested by: ${username}`;
|
||||||
meta.appendChild(user);
|
} else {
|
||||||
|
user.textContent = 'Requested by: Unknown (Ombi)';
|
||||||
|
user.title = 'No user information received from Ombi';
|
||||||
|
user.style.cursor = 'help';
|
||||||
|
user.style.textDecoration = 'underline dotted';
|
||||||
|
}
|
||||||
|
meta.appendChild(user);
|
||||||
|
|
||||||
|
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date;
|
||||||
|
if (dateStr) {
|
||||||
|
const requestDate = document.createElement('span');
|
||||||
|
requestDate.className = 'request-date';
|
||||||
|
try {
|
||||||
|
const dateObj = new Date(dateStr);
|
||||||
|
if (!isNaN(dateObj.getTime())) {
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(dateObj.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
|
||||||
|
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
meta.appendChild(requestDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.quality) {
|
if (request.quality) {
|
||||||
@@ -147,6 +209,22 @@ function createRequestCard(request) {
|
|||||||
actions.appendChild(ombiLink);
|
actions.appendChild(ombiLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && request.arrLink) {
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.className = `request-link ${request.arrType}-link`;
|
||||||
|
arrLink.href = request.arrLink;
|
||||||
|
arrLink.target = '_blank';
|
||||||
|
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
|
||||||
|
|
||||||
|
const arrIcon = document.createElement('img');
|
||||||
|
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||||
|
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
arrIcon.className = 'request-icon';
|
||||||
|
|
||||||
|
arrLink.appendChild(arrIcon);
|
||||||
|
actions.appendChild(arrLink);
|
||||||
|
}
|
||||||
|
|
||||||
card.appendChild(typeIcon);
|
card.appendChild(typeIcon);
|
||||||
card.appendChild(content);
|
card.appendChild(content);
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|||||||
+29
-10
@@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js';
|
|||||||
|
|
||||||
// Apply saved theme immediately on load
|
// Apply saved theme immediately on load
|
||||||
(function applyTheme() {
|
(function applyTheme() {
|
||||||
const theme = getTheme();
|
const theme = getTheme() || 'light';
|
||||||
if (theme) {
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function initThemeSwitcher() {
|
export function initThemeSwitcher() {
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
if (!themeToggle) return;
|
const currentTheme = getTheme() || 'light';
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
// Set initial active state on buttons
|
||||||
const currentTheme = getTheme();
|
themeButtons.forEach(btn => {
|
||||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||||
setTheme(newTheme);
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const theme = btn.getAttribute('data-theme');
|
||||||
|
if (theme) {
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme) {
|
export function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
saveTheme(theme);
|
saveTheme(theme);
|
||||||
|
|
||||||
|
// Sync button active classes if elements are present on the page
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
themeButtons.forEach(btn => {
|
||||||
|
if (btn.getAttribute('data-theme') === theme) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.15",
|
"version": "1.7.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.15",
|
"version": "1.7.22",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.15",
|
"version": "1.7.22",
|
||||||
"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": {
|
||||||
|
|||||||
+21
-19
File diff suppressed because one or more lines are too long
+19
-1
@@ -1888,6 +1888,23 @@ body {
|
|||||||
|
|
||||||
/* ===== Mobile ===== */
|
/* ===== Mobile ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.main-tabs {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2234,6 +2251,7 @@ body {
|
|||||||
.requests-list {
|
.requests-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card {
|
.request-card {
|
||||||
@@ -2245,6 +2263,7 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card:hover {
|
.request-card:hover {
|
||||||
@@ -2273,7 +2292,6 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.14"
|
* example: "1.7.22"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users from Ombi
|
||||||
|
* @returns {Promise<Array>} Array of user objects
|
||||||
|
*/
|
||||||
|
async getUsers() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = OmbiClient;
|
module.exports = OmbiClient;
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
this.cache = {
|
this.cache = {
|
||||||
movieRequests: [],
|
movieRequests: [],
|
||||||
tvRequests: [],
|
tvRequests: [],
|
||||||
|
users: [],
|
||||||
movieMap: new Map(), // tmdbId -> request
|
movieMap: new Map(), // tmdbId -> request
|
||||||
tvMap: new Map(), // tvdbId -> request
|
tvMap: new Map(), // tvdbId -> request
|
||||||
|
userMap: new Map(), // id -> user
|
||||||
lastFetch: 0,
|
lastFetch: 0,
|
||||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||||
};
|
};
|
||||||
@@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
try {
|
try {
|
||||||
logToFile('[OmbiRetriever] Refreshing cache');
|
logToFile('[OmbiRetriever] Refreshing cache');
|
||||||
|
|
||||||
// Fetch requests in parallel
|
// Fetch requests and users in parallel
|
||||||
const [movieRequests, tvRequests] = await Promise.all([
|
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||||
this.client.getMovieRequests(),
|
this.client.getMovieRequests(),
|
||||||
this.client.getTvRequests()
|
this.client.getTvRequests(),
|
||||||
|
this.client.getUsers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cache.movieRequests = movieRequests;
|
this.cache.movieRequests = movieRequests;
|
||||||
this.cache.tvRequests = tvRequests;
|
this.cache.tvRequests = tvRequests;
|
||||||
|
this.cache.users = users;
|
||||||
this.cache.lastFetch = Date.now();
|
this.cache.lastFetch = Date.now();
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
this.cache.movieMap.clear();
|
this.cache.movieMap.clear();
|
||||||
this.cache.tvMap.clear();
|
this.cache.tvMap.clear();
|
||||||
|
this.cache.userMap.clear();
|
||||||
|
|
||||||
|
// Build user map (id -> user)
|
||||||
|
if (Array.isArray(users)) {
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user && user.id) {
|
||||||
|
this.cache.userMap.set(user.id, user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Build movie map (tmdbId -> request)
|
// Build movie map (tmdbId -> request)
|
||||||
movieRequests.forEach(request => {
|
movieRequests.forEach(request => {
|
||||||
@@ -133,13 +147,59 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||||
// Don't throw error, continue with stale cache if available
|
// Don't throw error, continue with stale cache if available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a single request using the userMap cache
|
||||||
|
* @param {Object} req - The request object
|
||||||
|
* @returns {Object} Hydrated request object
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequest(req) {
|
||||||
|
if (!req) return req;
|
||||||
|
|
||||||
|
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||||
|
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||||
|
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||||
|
|
||||||
|
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||||
|
|
||||||
|
// If requestedUser is not an object or is empty/null, populate it
|
||||||
|
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||||
|
const hydratedUser = {
|
||||||
|
id: cachedUser.id,
|
||||||
|
userName: cachedUser.userName,
|
||||||
|
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||||
|
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||||
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...req,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||||
|
* @param {Array} requests - Array of request objects
|
||||||
|
* @returns {Array} Array of hydrated request objects
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequests(requests) {
|
||||||
|
if (!Array.isArray(requests)) return [];
|
||||||
|
return requests.map(req => this._hydrateRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all movie requests
|
* Get all movie requests
|
||||||
* @param {boolean} force - Whether to force refresh from API
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
@@ -147,7 +207,7 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getMovieRequests(force = false) {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
await this.refreshCache(force);
|
||||||
return this.cache.movieRequests;
|
return this._hydrateRequests(this.cache.movieRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +217,7 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getTvRequests(force = false) {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
await this.refreshCache(force);
|
||||||
return this.cache.tvRequests;
|
return this._hydrateRequests(this.cache.tvRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,12 +231,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TMDB ID first
|
// Try TMDB ID first
|
||||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||||
return this.cache.movieMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try IMDB ID as fallback
|
// Try IMDB ID as fallback
|
||||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||||
return this.cache.movieMap.get(imdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -193,12 +253,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TVDB ID first
|
// Try TVDB ID first
|
||||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||||
return this.cache.tvMap.get(tvdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try TMDB ID as fallback
|
// Try TMDB ID as fallback
|
||||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||||
return this.cache.tvMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.14"
|
* example: "1.7.22"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
|
|||||||
+22
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.15
|
version: 1.7.22
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -176,6 +176,27 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||||
example: "Request"
|
example: "Request"
|
||||||
|
arrLink:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
format: uri
|
||||||
|
description: Sonarr/Radarr show/movie web UI link (admin-only)
|
||||||
|
example: "http://sonarr:8989/series/show-slug"
|
||||||
|
downloadPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Save path in download client (admin-only)
|
||||||
|
example: "/downloads/series/show-slug"
|
||||||
|
targetPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Target path in library (admin-only)
|
||||||
|
example: "/tv/show-slug"
|
||||||
|
arrInstanceKey:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Sonarr/Radarr instance API key (admin-only)
|
||||||
|
example: "api-key-here"
|
||||||
|
|
||||||
DashboardPayload:
|
DashboardPayload:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+52
-10
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
|||||||
function buildMetadataMaps(snapshot) {
|
function buildMetadataMaps(snapshot) {
|
||||||
const seriesMap = new Map();
|
const seriesMap = new Map();
|
||||||
for (const r of snapshot.sonarrQueue.data.records) {
|
for (const r of snapshot.sonarrQueue.data.records) {
|
||||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
if (r.series && r.seriesId) {
|
||||||
|
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||||
|
r.series._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const r of snapshot.sonarrHistory.data.records) {
|
for (const r of snapshot.sonarrHistory.data.records) {
|
||||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
if (r.series && r.seriesId) {
|
||||||
|
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||||
|
r.series._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
const existing = seriesMap.get(r.seriesId);
|
||||||
|
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
|
||||||
|
seriesMap.set(r.seriesId, r.series);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const moviesMap = new Map();
|
const moviesMap = new Map();
|
||||||
for (const r of snapshot.radarrQueue.data.records) {
|
for (const r of snapshot.radarrQueue.data.records) {
|
||||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
if (r.movie && r.movieId) {
|
||||||
|
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||||
|
r.movie._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const r of snapshot.radarrHistory.data.records) {
|
for (const r of snapshot.radarrHistory.data.records) {
|
||||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
if (r.movie && r.movieId) {
|
||||||
|
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||||
|
r.movie._instanceUrl = r._instanceUrl;
|
||||||
|
}
|
||||||
|
const existing = moviesMap.get(r.movieId);
|
||||||
|
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
|
||||||
|
moviesMap.set(r.movieId, r.movie);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||||
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
||||||
@@ -686,17 +712,33 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the download to verify permission
|
// Look up the queue record directly from the *arr cache.
|
||||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
// downloadClientRegistry.getAllDownloads() returns raw download-client data
|
||||||
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
|
||||||
|
// is only populated later by DownloadMatcher during the SSE build phase.
|
||||||
|
// Instead, we verify permission by finding the record in the Sonarr/Radarr
|
||||||
|
// queue cache where record.id is the numeric queue ID.
|
||||||
|
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
|
||||||
|
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
|
||||||
|
const queueData = cache.get(queueCacheKey) || { records: [] };
|
||||||
|
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
|
||||||
|
|
||||||
if (!download) {
|
if (!queueRecord) {
|
||||||
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
|
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Download not found or permission denied' });
|
return res.status(403).json({ error: 'Download not found or permission denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a minimal download-like object for canBlocklist eligibility check.
|
||||||
|
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
|
||||||
|
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
|
||||||
|
const downloadForCheck = {
|
||||||
|
importIssues: importIssues || [],
|
||||||
|
arrQueueId: queueRecord.id,
|
||||||
|
arrType
|
||||||
|
};
|
||||||
|
|
||||||
// Check if user can blocklist this download
|
// Check if user can blocklist this download
|
||||||
if (!canBlocklist(download, user.isAdmin)) {
|
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
|
||||||
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
|
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-21
@@ -2,7 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
@@ -120,6 +120,66 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||||
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
|
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
// Fetch all series and movies in parallel to match
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For TV requests, find match in Sonarr
|
||||||
|
filteredTvRequests.forEach(req => {
|
||||||
|
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
if (!tvdbId) return;
|
||||||
|
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
req.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For Movie requests, find match in Radarr
|
||||||
|
filteredMovieRequests.forEach(req => {
|
||||||
|
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
|
||||||
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
req.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Tag with mediaType and flatten for filtering/sorting
|
// Tag with mediaType and flatten for filtering/sorting
|
||||||
const allRequests = [
|
const allRequests = [
|
||||||
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
|
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
|
||||||
@@ -205,10 +265,10 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -221,7 +281,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||||
|
|
||||||
// Call Ombi API to register webhook
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
@@ -462,10 +522,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -478,25 +538,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
// Simulate a test webhook event
|
// Simulate a test webhook event
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
await axios.post(webhookUrl, {
|
try {
|
||||||
notificationType: 'RequestAvailable',
|
await axios.post(webhookUrl, {
|
||||||
requestId: 0,
|
notificationType: 'RequestAvailable',
|
||||||
requestedUser: 'test',
|
requestId: 0,
|
||||||
title: 'Test Request',
|
requestedUser: 'test',
|
||||||
type: 'Movie',
|
title: 'Test Request',
|
||||||
requestStatus: 'Pending'
|
type: 'Movie',
|
||||||
}, {
|
requestStatus: 'Pending'
|
||||||
headers: {
|
}, {
|
||||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
let useHttps = false;
|
||||||
|
if (tlsEnabled) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const certsDir = path.join(__dirname, '../../certs');
|
||||||
|
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
|
||||||
|
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
|
||||||
|
try {
|
||||||
|
fs.readFileSync(tlsCertPath);
|
||||||
|
fs.readFileSync(tlsKeyPath);
|
||||||
|
useHttps = true;
|
||||||
|
} catch {
|
||||||
|
useHttps = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
|
||||||
|
const https = require('https');
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post(localUrl, {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 0,
|
||||||
|
requestedUser: 'test',
|
||||||
|
title: 'Test Request',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending'
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
httpsAgent: useHttps ? agent : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Radarr instance (for notification proxy routes)
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
function getFirstRadarrInstance() {
|
function getFirstRadarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Radarr not configured' });
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Sonarr instance (for notification proxy routes)
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
function getFirstSonarrInstance() {
|
function getFirstSonarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function validateWebhookSecret(req) {
|
|||||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
@@ -259,9 +259,66 @@ 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
|
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
||||||
|
await new Promise(r => setTimeout(r, initialDelay));
|
||||||
|
|
||||||
|
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
||||||
|
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
||||||
|
|
||||||
|
let ombiRequests = { movie: [], tv: [] };
|
||||||
|
let foundAndValid = false;
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelayMs = 1500;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 1) {
|
||||||
|
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in movie or tv lists
|
||||||
|
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
||||||
|
// Also check both if mediaType not specified
|
||||||
|
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
const user = extractRequestedUser(targetReq);
|
||||||
|
if (user) {
|
||||||
|
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAndValid && requestId) {
|
||||||
|
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
||||||
|
// Try to log the raw target request if we found one
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)`);
|
||||||
}
|
}
|
||||||
@@ -777,7 +834,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background cache refresh + SSE broadcast (fire-and-forget)
|
// Background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
processWebhookEvent('ombi', eventType).catch(err => {
|
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = series.path || null;
|
dlObj.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = movie.path || null;
|
dlObj.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = series.path || null;
|
download.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = movie.path || null;
|
download.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
|
|||||||
return process.env.SOFARR_BASE_URL || '';
|
return process.env.SOFARR_BASE_URL || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSofarrWebhookBaseUrl() {
|
||||||
|
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -140,6 +144,7 @@ module.exports = {
|
|||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
getWebhookSecret,
|
getWebhookSecret,
|
||||||
getSofarrBaseUrl,
|
getSofarrBaseUrl,
|
||||||
|
getSofarrWebhookBaseUrl,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
+52
-12
@@ -5,6 +5,8 @@
|
|||||||
* not a string, so we need to extract the username from the object.
|
* not a string, so we need to extract the username from the object.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the username from an Ombi request object.
|
* Extracts the username from an Ombi request object.
|
||||||
* Handles both the OmbiUser object format and legacy string format.
|
* Handles both the OmbiUser object format and legacy string format.
|
||||||
@@ -15,19 +17,57 @@
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
request.user || request.User ||
|
||||||
if (requestedUser && typeof requestedUser === 'object') {
|
request.requestedBy || request.RequestedBy ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.ombiUser || request.OmbiUser ||
|
||||||
return requestedUser.alias || requestedUser.Alias ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
|
||||||
requestedUser.userName || requestedUser.UserName ||
|
// If userSource is an object, extract key fields
|
||||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
if (userSource && typeof userSource === 'object') {
|
||||||
request.requestedByAlias || request.RequestedByAlias || '';
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
// If userSource is a string and not an empty object/array
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warning log when user extraction returns empty for non-empty requests
|
||||||
|
if (Object.keys(request).length > 0 && !request.notificationType) {
|
||||||
|
logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRequestsByUser(requests, username, showAll) {
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/requests.js
|
||||||
|
*
|
||||||
|
* Verifies requests dashboard rendering, tooltips, dates, and deep links.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/state.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
ombiRequests: { movie: [], tv: [] },
|
||||||
|
selectedRequestTypes: ['movie', 'tv'],
|
||||||
|
selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'],
|
||||||
|
requestSortMode: 'requestedDate_desc',
|
||||||
|
requestSearchQuery: '',
|
||||||
|
ombiBaseUrl: 'https://ombi.test',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requests rendering', () => {
|
||||||
|
let requestsList, noRequests;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="requests-list"></div>
|
||||||
|
<div id="no-requests" style="display: none;"><p></p></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
requestsList = document.getElementById('requests-list');
|
||||||
|
noRequests = document.getElementById('no-requests');
|
||||||
|
|
||||||
|
state.ombiRequests = { movie: [], tv: [] };
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiBaseUrl = 'https://ombi.test';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No requests found." when request arrays are empty', () => {
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(0);
|
||||||
|
expect(noRequests.style.display).toBe('block');
|
||||||
|
expect(noRequests.querySelector('p').textContent).toBe('No requests found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders request card with correctly formatted date, media type, and requester', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
year: '2026',
|
||||||
|
requestedUser: { alias: 'john_doe' },
|
||||||
|
requestedDate: '2026-05-27T10:15:30.000Z',
|
||||||
|
quality: '1080p',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.request-title').textContent).toBe('Movie Test');
|
||||||
|
expect(card.querySelector('.request-year').textContent).toBe('2026');
|
||||||
|
expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe');
|
||||||
|
|
||||||
|
// Check formatted date
|
||||||
|
const dateEl = card.querySelector('.request-date');
|
||||||
|
expect(dateEl).toBeTruthy();
|
||||||
|
expect(dateEl.textContent).toContain('Date: 2026-05-27');
|
||||||
|
|
||||||
|
// Check view in Ombi link
|
||||||
|
const ombiLink = card.querySelector('.ombi-link');
|
||||||
|
expect(ombiLink).toBeTruthy();
|
||||||
|
expect(ombiLink.href).toBe('https://ombi.test/details/movie/555');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => {
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 201,
|
||||||
|
title: 'TV Test No User',
|
||||||
|
requestedDate: '2026-05-27T12:00:00.000Z',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(1);
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
const userEl = card.querySelector('.request-user');
|
||||||
|
expect(userEl).toBeTruthy();
|
||||||
|
expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)');
|
||||||
|
expect(userEl.title).toBe('No user information received from Ombi');
|
||||||
|
expect(userEl.style.textDecoration).toBe('underline dotted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render Sonarr/Radarr deep links for non-admin users', () => {
|
||||||
|
state.isAdmin = false;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: []
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
const card = requestsList.childNodes[0];
|
||||||
|
expect(card.querySelector('.radarr-link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => {
|
||||||
|
state.isAdmin = true;
|
||||||
|
state.ombiRequests = {
|
||||||
|
movie: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Movie Test',
|
||||||
|
theMovieDbId: 555,
|
||||||
|
arrLink: 'http://radarr:7878/movie/slug',
|
||||||
|
arrType: 'radarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tv: [
|
||||||
|
{
|
||||||
|
id: 202,
|
||||||
|
title: 'TV Show Test',
|
||||||
|
theMovieDbId: 666,
|
||||||
|
arrLink: 'http://sonarr:8989/series/slug',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
requested: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
renderRequests();
|
||||||
|
|
||||||
|
expect(requestsList.childNodes.length).toBe(2);
|
||||||
|
|
||||||
|
// Check Radarr link
|
||||||
|
const movieCard = requestsList.childNodes[0];
|
||||||
|
const radarrLink = movieCard.querySelector('.radarr-link');
|
||||||
|
expect(radarrLink).toBeTruthy();
|
||||||
|
expect(radarrLink.href).toBe('http://radarr:7878/movie/slug');
|
||||||
|
expect(radarrLink.title).toBe('View in Radarr');
|
||||||
|
|
||||||
|
// Check Sonarr link
|
||||||
|
const tvCard = requestsList.childNodes[1];
|
||||||
|
const sonarrLink = tvCard.querySelector('.sonarr-link');
|
||||||
|
expect(sonarrLink).toBeTruthy();
|
||||||
|
expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug');
|
||||||
|
expect(sonarrLink.title).toBe('View in Sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/theme.js
|
||||||
|
*
|
||||||
|
* Verifies DOM actions for theme switcher button clicks, attributes, and storage calls.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js';
|
||||||
|
import * as storage from '../../../client/src/utils/storage.js';
|
||||||
|
|
||||||
|
vi.mock('../../../client/src/utils/storage.js', () => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getTheme: vi.fn(() => store.theme || 'light'),
|
||||||
|
saveTheme: vi.fn((theme) => { store.theme = theme; })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('theme switcher', () => {
|
||||||
|
let lightBtn, darkBtn, monoBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
|
||||||
|
// Create mock theme buttons
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
lightBtn = document.querySelector('[data-theme="light"]');
|
||||||
|
darkBtn = document.querySelector('[data-theme="dark"]');
|
||||||
|
monoBtn = document.querySelector('[data-theme="mono"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher sets active class based on saved theme on load', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue('dark');
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(storage.getTheme).toHaveBeenCalled();
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initThemeSwitcher defaults to light theme if no theme is saved', () => {
|
||||||
|
vi.spyOn(storage, 'getTheme').mockReturnValue(null);
|
||||||
|
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking theme button switches the document theme and persists choice', () => {
|
||||||
|
initThemeSwitcher();
|
||||||
|
|
||||||
|
// Initial active button should be light
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
// Click Dark
|
||||||
|
darkBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('dark');
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
|
||||||
|
// Click Mono
|
||||||
|
monoBtn.click();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(darkBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTheme directly sets document attribute and updates button classes if present', () => {
|
||||||
|
initThemeSwitcher(); // binds buttons
|
||||||
|
|
||||||
|
setTheme('mono');
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('mono');
|
||||||
|
expect(storage.saveTheme).toHaveBeenCalledWith('mono');
|
||||||
|
expect(monoBtn.classList.contains('active')).toBe(true);
|
||||||
|
expect(lightBtn.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -225,7 +225,7 @@ function invalidatePollCache() {
|
|||||||
'poll:sab-queue', 'poll:sab-history',
|
'poll:sab-queue', 'poll:sab-history',
|
||||||
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||||
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
||||||
'poll:qbittorrent'
|
'poll:qbittorrent', 'poll:ombi-requests'
|
||||||
];
|
];
|
||||||
for (const k of keys) cache.invalidate(k);
|
for (const k of keys) cache.invalidate(k);
|
||||||
}
|
}
|
||||||
@@ -349,6 +349,7 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.arrQueueId).toBe(1002);
|
expect(dl.arrQueueId).toBe(1002);
|
||||||
expect(dl.arrType).toBe('sonarr');
|
expect(dl.arrType).toBe('sonarr');
|
||||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||||
|
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||||
expect(dl.downloadPath).toBeDefined();
|
expect(dl.downloadPath).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -749,12 +750,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
|
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
@@ -763,18 +766,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/permission denied/i);
|
expect(res.body.error).toMatch(/permission denied/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 403 for non-admin when download not found in active downloads', async () => {
|
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return empty array (download not found)
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
|
|
||||||
|
|
||||||
|
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
@@ -782,19 +781,21 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/download not found/i);
|
expect(res.body.error).toMatch(/download not found/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
|
// Seed cache: queue record with import issues — qualifies non-admin for blocklist
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['Import error 1'] }]
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
// Mock Sonarr DELETE and command endpoints
|
// Mock Sonarr DELETE and command endpoints
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
@@ -812,7 +813,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 when required fields are missing', async () => {
|
it('returns 400 when required fields are missing', async () => {
|
||||||
@@ -843,11 +843,8 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -864,18 +861,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Radarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(RADARR_BASE)
|
nock(RADARR_BASE)
|
||||||
.delete('/api/v3/queue/2001')
|
.delete('/api/v3/queue/2001')
|
||||||
@@ -892,18 +885,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -916,17 +905,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.set('X-CSRF-Token', csrf)
|
.set('X-CSRF-Token', csrf)
|
||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -943,17 +929,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -970,7 +953,42 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
});
|
||||||
|
|
||||||
|
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
|
||||||
|
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
|
||||||
|
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
|
||||||
|
// Without String() casting the === comparison fails and returns 403.
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
// Seed Radarr queue with a numeric id (as Radarr API returns it)
|
||||||
|
cache.set('poll:radarr-queue', { records: [{
|
||||||
|
id: 9050001,
|
||||||
|
title: 'Project.Hail.Mary.2026.2160p',
|
||||||
|
movieId: 77,
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
_instanceUrl: RADARR_BASE,
|
||||||
|
_instanceKey: 'rk'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.delete('/api/v3/queue/9050001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.post('/api/v3/command')
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
|
||||||
|
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1014,10 +1014,16 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
expect(webhookScope.isDone()).toBe(true);
|
expect(webhookScope.isDone()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles webhook send errors gracefully', async () => {
|
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
|
||||||
nock(SOFARR_BASE)
|
nock(SOFARR_BASE)
|
||||||
.post('/api/webhook/ombi')
|
.post('/api/webhook/ombi')
|
||||||
.reply(500, { error: 'Internal server error' });
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
|
||||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
@@ -1029,4 +1035,26 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
|
|
||||||
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to local loopback when public URL request fails', async () => {
|
||||||
|
nock(SOFARR_BASE)
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.replyWithError('Connection refused');
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
|
||||||
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ombi/webhook/test')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getUsers', () => {
|
||||||
|
it('should return user array for successful request', async () => {
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: '1', userName: 'Gordon' },
|
||||||
|
{ id: '2', userName: 'Alice' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on API error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(500, { error: 'Internal Server Error' });
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on network error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.replyWithError('Network error');
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -766,4 +766,70 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hydration logic', () => {
|
||||||
|
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser).toBeDefined();
|
||||||
|
expect(result[0].requestedUser.userName).toBe('Gordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('G-Man');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not overwrite non-empty requestedUser object', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Movie 1',
|
||||||
|
requestedUserId: 'gordon-id',
|
||||||
|
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('ExistingG');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,57 @@ describe('ombiHelpers', () => {
|
|||||||
};
|
};
|
||||||
expect(extractRequestedUser(req)).toBe('');
|
expect(extractRequestedUser(req)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns userName from nested user object', () => {
|
||||||
|
const req = { user: { userName: 'user_val' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('user_val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alias from nested requestedBy object', () => {
|
||||||
|
const req = { requestedBy: { alias: 'req_alias' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('req_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalizedUserName from nested ombiUser object', () => {
|
||||||
|
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('norm_ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns userAlias from nested requestedByUser object', () => {
|
||||||
|
const req = { requestedByUser: { userAlias: 'alias_user' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('alias_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from a string source value', () => {
|
||||||
|
const req = { requestedBy: 'direct_string' };
|
||||||
|
expect(extractRequestedUser(req)).toBe('direct_string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
|
||||||
|
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
|
||||||
|
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
|
||||||
|
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from seasons array requests', () => {
|
||||||
|
const req = {
|
||||||
|
seasons: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { alias: 'season_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('season_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests array', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ user: { userName: 'child_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('child_user');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterRequestsByUser', () => {
|
describe('filterRequestsByUser', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user