merge branch 'develop' into 'main' - Release v1.7.22
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
CI / Security audit (push) Successful in 2m26s
CI / Tests & coverage (push) Successful in 2m55s
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
CI / Security audit (push) Successful in 2m26s
CI / Tests & coverage (push) Successful in 2m55s
This commit is contained in:
@@ -169,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# =============================================================================
|
||||
OMBI_URL=https://ombi.example.com
|
||||
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
|
||||
|
||||
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.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
|
||||
|
||||
+91
-13
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
||||
function extractRequestedUser(request) {
|
||||
if (!request) return '';
|
||||
|
||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||
return request.requestedUser.alias ||
|
||||
request.requestedUser.userAlias ||
|
||||
request.requestedUser.userName ||
|
||||
request.requestedUser.normalizedUserName ||
|
||||
request.requestedByAlias || '';
|
||||
// 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;
|
||||
|
||||
// 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() {
|
||||
@@ -111,11 +146,38 @@ function createRequestCard(request) {
|
||||
}
|
||||
|
||||
const username = extractRequestedUser(request);
|
||||
const user = document.createElement('span');
|
||||
user.className = 'request-user';
|
||||
if (username) {
|
||||
const user = document.createElement('span');
|
||||
user.className = 'request-user';
|
||||
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) {
|
||||
@@ -147,6 +209,22 @@ function createRequestCard(request) {
|
||||
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(content);
|
||||
card.appendChild(actions);
|
||||
|
||||
+29
-10
@@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js';
|
||||
|
||||
// Apply saved theme immediately on load
|
||||
(function applyTheme() {
|
||||
const theme = getTheme();
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
const theme = getTheme() || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
|
||||
export function initThemeSwitcher() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (!themeToggle) return;
|
||||
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||
const currentTheme = getTheme() || 'light';
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = getTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
// Set initial active state on buttons
|
||||
themeButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||
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) {
|
||||
document.documentElement.setAttribute('data-theme', 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",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.21",
|
||||
"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",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+21
-19
File diff suppressed because one or more lines are too long
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.21"
|
||||
* example: "1.7.22"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.21"
|
||||
* example: "1.7.22"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
||||
|
||||
## SSE Streaming
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.7.21
|
||||
version: 1.7.22
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
|
||||
@@ -120,6 +120,66 @@ router.get('/requests', requireAuth, async (req, res) => {
|
||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], 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
|
||||
const allRequests = [
|
||||
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
|
||||
|
||||
@@ -180,7 +180,7 @@ function validateWebhookSecret(req) {
|
||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||
* @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 affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||
@@ -259,9 +259,66 @@ async function processWebhookEvent(serviceType, eventType) {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
if (affectsOmbi) {
|
||||
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||
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);
|
||||
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)
|
||||
processWebhookEvent('ombi', eventType).catch(err => {
|
||||
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||
});
|
||||
|
||||
|
||||
+52
-12
@@ -5,6 +5,8 @@
|
||||
* 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.
|
||||
* Handles both the OmbiUser object format and legacy string format.
|
||||
@@ -15,19 +17,57 @@
|
||||
function extractRequestedUser(request) {
|
||||
if (!request) return '';
|
||||
|
||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
||||
|
||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||
if (requestedUser && typeof requestedUser === 'object') {
|
||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||
return requestedUser.alias || requestedUser.Alias ||
|
||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
||||
requestedUser.userName || requestedUser.UserName ||
|
||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
||||
request.requestedByAlias || request.RequestedByAlias || '';
|
||||
// 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;
|
||||
|
||||
// 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 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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,57 @@ describe('ombiHelpers', () => {
|
||||
};
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user