Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 | |||
| 0eaa54cf4a | |||
| 865cf1f57a | |||
| ff5f50cc3a | |||
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 |
@@ -169,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
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'release/**'
|
- 'release/**'
|
||||||
- 'develop*'
|
- 'develop*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -23,20 +27,14 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building develop image tags: ${TAGS}"
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
# Primary registry tags
|
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
|
||||||
|
|
||||||
# Gitea package registry tags
|
# Gitea package registry tags
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
audit:
|
audit:
|
||||||
|
|||||||
@@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Ombi & *Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and *Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
|
||||||
|
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
|
||||||
|
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
|
||||||
|
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
|
||||||
|
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
|
||||||
|
|
||||||
|
## [1.7.23] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
|
||||||
|
|
||||||
|
## [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
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+99
-19
@@ -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,39 @@ 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 childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
|
||||||
|
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
|
||||||
|
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) {
|
||||||
@@ -128,25 +191,42 @@ function createRequestCard(request) {
|
|||||||
content.appendChild(title);
|
content.appendChild(title);
|
||||||
content.appendChild(meta);
|
content.appendChild(meta);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'request-actions';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'request-link ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
|
||||||
ombiLink.target = '_blank';
|
ombiLink.target = '_blank';
|
||||||
ombiLink.title = 'View in Ombi';
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
const ombiIcon = document.createElement('img');
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.className = 'service-icon ombi';
|
||||||
ombiIcon.src = '/images/ombi.svg';
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
ombiIcon.alt = 'Ombi';
|
ombiIcon.alt = 'Ombi';
|
||||||
ombiIcon.className = 'request-icon';
|
|
||||||
|
|
||||||
ombiLink.appendChild(ombiIcon);
|
ombiLink.appendChild(ombiIcon);
|
||||||
actions.appendChild(ombiLink);
|
actions.appendChild(ombiLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && request.arrLink) {
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.className = `${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.className = `service-icon ${request.arrType}`;
|
||||||
|
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||||
|
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.21",
|
"version": "1.7.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.21",
|
"version": "1.7.28",
|
||||||
"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.21",
|
"version": "1.7.28",
|
||||||
"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
+40
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.21"
|
* example: "1.7.28"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
@@ -232,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||||
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||||
|
app.use(express.static(PUBLIC_DIR, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
_hydrateRequest(req) {
|
_hydrateRequest(req) {
|
||||||
if (!req) return req;
|
if (!req) return req;
|
||||||
|
|
||||||
|
let result = req;
|
||||||
|
|
||||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||||
@@ -179,14 +181,55 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
...req,
|
...req,
|
||||||
requestedUser: hydratedUser,
|
requestedUser: hydratedUser,
|
||||||
RequestedUser: hydratedUser
|
RequestedUser: hydratedUser
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return req;
|
|
||||||
|
// Hydrate childRequests (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const hydratedChildren = result.childRequests.map(child => {
|
||||||
|
if (!child) return child;
|
||||||
|
|
||||||
|
const childUserId = child.requestedUserId || child.RequestedUserId;
|
||||||
|
if (childUserId && this.cache.userMap.has(childUserId)) {
|
||||||
|
const cachedUser = this.cache.userMap.get(childUserId);
|
||||||
|
let childUser = child.requestedUser || child.RequestedUser;
|
||||||
|
|
||||||
|
if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).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 {
|
||||||
|
...child,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
result = { ...result, childRequests: hydratedChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote requestedDate from childRequests to top level (common for Ombi TV)
|
||||||
|
if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) {
|
||||||
|
const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate;
|
||||||
|
if (childDate) {
|
||||||
|
result = { ...result, requestedDate: childDate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-287
@@ -82,20 +82,9 @@ console.error = function(...args) {
|
|||||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
|
||||||
const radarrRoutes = require('./routes/radarr');
|
|
||||||
const embyRoutes = require('./routes/emby');
|
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
|
||||||
const statusRoutes = require('./routes/status');
|
|
||||||
const historyRoutes = require('./routes/history');
|
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
const webhookRoutes = require('./routes/webhook');
|
|
||||||
const ombiRoutes = require('./routes/ombi');
|
|
||||||
const debugRoutes = require('./routes/debug');
|
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Startup environment validation
|
// Startup environment validation
|
||||||
@@ -117,284 +106,10 @@ if (process.env.EMBY_URL) {
|
|||||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Load OpenAPI spec from YAML
|
|
||||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
||||||
|
|
||||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
||||||
const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
...openapiSpec,
|
|
||||||
openapi: '3.1.0'
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, 'routes/*.js'),
|
|
||||||
path.join(__dirname, 'index.js')
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
||||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
||||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (process.env.TRUST_PROXY) {
|
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
|
||||||
: process.env.TRUST_PROXY;
|
|
||||||
app.set('trust proxy', trustValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helmet v7 — security response headers
|
|
||||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
||||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// Generate a fresh nonce for every request
|
|
||||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
formAction: ["'self'"],
|
|
||||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000, // 1 year
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true
|
|
||||||
},
|
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// General API rate limiter — applies to all /api/* routes
|
|
||||||
// More specific limiters (e.g. login) apply on top of this.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
|
||||||
message: { error: 'Too many requests, please try again later' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body parsing & cookies
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use(cookieParser(cookieSecret || undefined));
|
|
||||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /health:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Health check
|
|
||||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is healthy
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ok"
|
|
||||||
* uptime:
|
|
||||||
* type: number
|
|
||||||
* description: Server uptime in seconds
|
|
||||||
* example: 3600.5
|
|
||||||
* version:
|
|
||||||
* type: string
|
|
||||||
* description: sofarr version
|
|
||||||
* example: "1.7.21"
|
|
||||||
*/
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /ready:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Readiness check
|
|
||||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ready"
|
|
||||||
* '503':
|
|
||||||
* description: Server not ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "not ready"
|
|
||||||
* reason:
|
|
||||||
* type: string
|
|
||||||
* example: "EMBY_URL not configured"
|
|
||||||
*/
|
|
||||||
app.get('/ready', (req, res) => {
|
|
||||||
// Confirm critical config is present
|
|
||||||
const ready = !!(process.env.EMBY_URL);
|
|
||||||
if (ready) {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
} else {
|
|
||||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Swagger UI - publicly accessible API documentation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
||||||
customSiteTitle: 'sofarr API Documentation',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
customJs: [
|
|
||||||
'/swagger-auth-banner.js'
|
|
||||||
],
|
|
||||||
swaggerOptions: {
|
|
||||||
url: '/api/swagger.json'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
||||||
app.get('/api/swagger.json', (req, res) => {
|
|
||||||
// Clone the spec to avoid modifying the original
|
|
||||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
||||||
|
|
||||||
// Replace the server URL with the current request's origin
|
|
||||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(specCopy);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static files — served before API routes
|
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html.
|
|
||||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
||||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
||||||
app.use(express.static(PUBLIC_DIR, {
|
|
||||||
index: false,
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve index.html with CSP nonce injected into <script> tags
|
|
||||||
function serveIndex(req, res) {
|
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
|
||||||
const nonce = res.locals.cspNonce;
|
|
||||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
||||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
||||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
||||||
// the old nonce which no longer matches the per-request CSP header).
|
|
||||||
const patched = html
|
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.send(patched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
||||||
// CSRF protection applies to all state-changing /api/* requests except
|
|
||||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api', apiLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/webhook', webhookRoutes);
|
|
||||||
app.use('/api/debug', debugRoutes);
|
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
|
||||||
app.use('/api', verifyCsrf);
|
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
|
||||||
app.use('/api/radarr', radarrRoutes);
|
|
||||||
app.use('/api/emby', embyRoutes);
|
|
||||||
app.use('/api/ombi', ombiRoutes);
|
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
|
||||||
app.use('/api/status', statusRoutes);
|
|
||||||
app.use('/api/history', historyRoutes);
|
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
|
||||||
app.get('*', serveIndex);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global error handler — never leak stack traces to clients
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('[Server] Unhandled error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TLS / HTTPS support
|
// TLS / HTTPS support
|
||||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
|||||||
+1
-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.21
|
version: 1.7.28
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
|||||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||||
|
|
||||||
|
|
||||||
@@ -525,8 +525,14 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||||
|
|
||||||
|
|
||||||
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
|
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
|
||||||
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
|
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
|
||||||
|
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
const ombiRequestsFiltered = {
|
const ombiRequestsFiltered = {
|
||||||
movie: filteredOmbiMovieRequests,
|
movie: filteredOmbiMovieRequests,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { logToFile } = require('../utils/logger');
|
|||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = 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, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -126,6 +126,11 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateRequestsWithArrLinks(allRequests, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
let types = req.query.type;
|
let types = req.query.type;
|
||||||
let statuses = req.query.status;
|
let statuses = req.query.status;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-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 ||
|
||||||
|
request.user || request.User ||
|
||||||
|
request.requestedBy || request.RequestedBy ||
|
||||||
|
request.ombiUser || request.OmbiUser ||
|
||||||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// If userSource is an object, extract key fields
|
||||||
if (requestedUser && typeof requestedUser === 'object') {
|
if (userSource && typeof userSource === 'object') {
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
const username = userSource.alias || userSource.Alias ||
|
||||||
return requestedUser.alias || requestedUser.Alias ||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
userSource.userName || userSource.UserName ||
|
||||||
requestedUser.userName || requestedUser.UserName ||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
request.requestedByAlias || request.RequestedByAlias || '';
|
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) {
|
||||||
@@ -40,7 +80,73 @@ function filterRequestsByUser(requests, username, showAll) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(requests)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
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: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
requests.forEach(req => {
|
||||||
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
|
// Fallback to checking for TV specific IDs.
|
||||||
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extractRequestedUser,
|
extractRequestedUser,
|
||||||
filterRequestsByUser
|
filterRequestsByUser,
|
||||||
|
decorateRequestsWithArrLinks
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
|
|||||||
@@ -183,3 +183,152 @@ describe('arrRetrieverRegistry', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OmbiRetriever._hydrateRequest', () => {
|
||||||
|
let retriever;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
retriever = new OmbiRetriever({
|
||||||
|
id: 'ombi-test',
|
||||||
|
name: 'Test Ombi',
|
||||||
|
url: 'http://localhost:5000',
|
||||||
|
apiKey: 'test-key'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the userMap cache
|
||||||
|
retriever.cache.userMap.set('user-1', {
|
||||||
|
id: 'user-1',
|
||||||
|
userName: 'testuser',
|
||||||
|
alias: 'TestUser',
|
||||||
|
userAlias: 'TestUser',
|
||||||
|
normalizedUserName: 'testuser'
|
||||||
|
});
|
||||||
|
retriever.cache.userMap.set('user-2', {
|
||||||
|
id: 'user-2',
|
||||||
|
userName: 'adminuser',
|
||||||
|
alias: 'AdminUser',
|
||||||
|
userAlias: 'AdminUser',
|
||||||
|
normalizedUserName: 'adminuser'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates top-level requestedUserId', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {}
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.requestedUser.alias).toBe('TestUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates childRequests requestedUserId (TV requests)', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: {},
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-2',
|
||||||
|
requestedUser: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedUser.userName).toBe('testuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('adminuser');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes requestedDate from childRequests to top level', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Show',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite existing top-level requestedDate', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
requestedDate: '2026-01-01T00:00:00.000Z',
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles PascalCase RequestedDate from childRequests', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
RequestedDate: '2026-06-01T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unmodified request when no hydration needed', () => {
|
||||||
|
const req = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Movie',
|
||||||
|
requestedUser: { userName: 'existing', alias: 'Existing' }
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: null
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty childRequests gracefully', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: []
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result).toEqual(req);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips child hydration when child already has valid requestedUser', () => {
|
||||||
|
const req = {
|
||||||
|
id: 3,
|
||||||
|
childRequests: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
requestedUserId: 'user-1',
|
||||||
|
requestedUser: { userName: 'already_set', alias: 'AlreadySet' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = retriever._hydrateRequest(req);
|
||||||
|
expect(result.childRequests[0].requestedUser.userName).toBe('already_set');
|
||||||
|
expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,40 @@ describe('getRequestStatus', () => {
|
|||||||
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||||
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns available from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns denied from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns approved from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows priority inside childRequests: available > denied > approved > pending', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ available: true, denied: true },
|
||||||
|
{ approved: true }
|
||||||
|
]})).toBe('available');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ denied: true, approved: true },
|
||||||
|
{ requested: true }
|
||||||
|
]})).toBe('denied');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ approved: true, requested: true }
|
||||||
|
]})).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for TV request with empty childRequests', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [] })).toBe('unknown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -79,6 +79,85 @@ 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { userName: 'tv_user', alias: 'tv_alias' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('tv_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser as string', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedUser: 'string_user' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('string_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedByAlias: 'deep_alias' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('deep_alias');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterRequestsByUser', () => {
|
describe('filterRequestsByUser', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user