Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 | |||
| 8fb00843ef | |||
| d2ac7731ca |
+10
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||
SOFARR_BASE_URL=https://your-sofarr-url
|
||||
|
||||
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
|
||||
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
|
||||
# Useful if those services reside in the same local network/docker container setup and
|
||||
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
|
||||
# Example: http://sofarr:3001 or http://192.168.1.50:3001
|
||||
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
|
||||
|
||||
# --- Webhook Polling Optimization (Phase 5) ---
|
||||
|
||||
# Minutes of silence after which the poller falls back to a full poll
|
||||
@@ -162,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# =============================================================================
|
||||
OMBI_URL=https://ombi.example.com
|
||||
OMBI_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
|
||||
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
- 'release/**'
|
||||
- 'develop*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,9 +2,13 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
branches: ["**", "!release/**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
branches: ["**", "!release/**"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
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/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
|
||||
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.21] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
|
||||
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.20] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
+96
-17
@@ -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,39 @@ 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 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) {
|
||||
@@ -128,25 +191,41 @@ function createRequestCard(request) {
|
||||
content.appendChild(title);
|
||||
content.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'request-actions';
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'service-icons-container';
|
||||
|
||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
||||
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.target = '_blank';
|
||||
ombiLink.title = 'View in Ombi';
|
||||
|
||||
const ombiIcon = document.createElement('img');
|
||||
ombiIcon.className = 'service-icon ombi';
|
||||
ombiIcon.src = '/images/ombi.svg';
|
||||
ombiIcon.alt = 'Ombi';
|
||||
ombiIcon.className = 'request-icon';
|
||||
|
||||
ombiLink.appendChild(ombiIcon);
|
||||
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(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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
|
||||
if (request.denied) return 'denied';
|
||||
if (request.approved) return 'approved';
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.20",
|
||||
"version": "1.7.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.20",
|
||||
"version": "1.7.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.20",
|
||||
"version": "1.7.25",
|
||||
"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.20"
|
||||
* example: "1.7.25"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
|
||||
@@ -163,12 +163,14 @@ class OmbiRetriever extends ArrRetriever {
|
||||
_hydrateRequest(req) {
|
||||
if (!req) return req;
|
||||
|
||||
let result = req;
|
||||
|
||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||
|
||||
|
||||
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||
|
||||
|
||||
// If requestedUser is not an object or is empty/null, populate it
|
||||
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||
const hydratedUser = {
|
||||
@@ -178,15 +180,56 @@ class OmbiRetriever extends ArrRetriever {
|
||||
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
result = {
|
||||
...req,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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.20"
|
||||
* example: "1.7.25"
|
||||
*/
|
||||
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.20
|
||||
version: 1.7.24
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
|
||||
+127
-21
@@ -2,7 +2,7 @@
|
||||
const express = require('express');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
const cache = require('../utils/cache');
|
||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||
@@ -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' })),
|
||||
@@ -205,10 +265,10 @@ router.get('/requests', requireAuth, async (req, res) => {
|
||||
*/
|
||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
if (!webhookBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
@@ -221,7 +281,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
const axios = require('axios');
|
||||
@@ -462,10 +522,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
||||
*/
|
||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
if (!webhookBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
@@ -478,25 +538,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||
|
||||
// Simulate a test webhook event
|
||||
const axios = require('axios');
|
||||
await axios.post(webhookUrl, {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 0,
|
||||
requestedUser: 'test',
|
||||
title: 'Test Request',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending'
|
||||
}, {
|
||||
headers: {
|
||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||
'Content-Type': 'application/json'
|
||||
try {
|
||||
await axios.post(webhookUrl, {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 0,
|
||||
requestedUser: 'test',
|
||||
title: 'Test Request',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending'
|
||||
}, {
|
||||
headers: {
|
||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
let useHttps = false;
|
||||
if (tlsEnabled) {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const certsDir = path.join(__dirname, '../../certs');
|
||||
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
|
||||
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
|
||||
try {
|
||||
fs.readFileSync(tlsCertPath);
|
||||
fs.readFileSync(tlsKeyPath);
|
||||
useHttps = true;
|
||||
} catch {
|
||||
useHttps = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||
|
||||
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||
|
||||
const https = require('https');
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
await axios.post(localUrl, {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 0,
|
||||
requestedUser: 'test',
|
||||
title: 'Test Request',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending'
|
||||
}, {
|
||||
headers: {
|
||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
httpsAgent: useHttps ? agent : undefined
|
||||
});
|
||||
|
||||
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||
|
||||
// Helper to get first Radarr instance (for notification proxy routes)
|
||||
function getFirstRadarrInstance() {
|
||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||
}
|
||||
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||
|
||||
const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
|
||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||
|
||||
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||
function getFirstSonarrInstance() {
|
||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
|
||||
if (!sofarrBaseUrl) {
|
||||
|
||||
if (!webhookBaseUrl) {
|
||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||
}
|
||||
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||
|
||||
const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
|
||||
@@ -180,7 +180,7 @@ function validateWebhookSecret(req) {
|
||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||
* @param {string} 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}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
|
||||
return process.env.SOFARR_BASE_URL || '';
|
||||
}
|
||||
|
||||
function getSofarrWebhookBaseUrl() {
|
||||
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
@@ -140,6 +144,7 @@ module.exports = {
|
||||
getRtorrentInstances,
|
||||
getWebhookSecret,
|
||||
getSofarrBaseUrl,
|
||||
getSofarrWebhookBaseUrl,
|
||||
parseInstances,
|
||||
validateInstanceUrl
|
||||
};
|
||||
|
||||
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
|
||||
if (request.denied) return 'denied';
|
||||
if (request.approved) return 'approved';
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
@@ -1014,10 +1014,16 @@ describe('POST /api/ombi/webhook/test', () => {
|
||||
expect(webhookScope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('handles webhook send errors gracefully', async () => {
|
||||
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
|
||||
nock(SOFARR_BASE)
|
||||
.post('/api/webhook/ombi')
|
||||
.reply(500, { error: 'Internal server error' });
|
||||
nock('http://127.0.0.1:3001')
|
||||
.post('/api/webhook/ombi')
|
||||
.reply(500, { error: 'Internal server error' });
|
||||
nock('https://127.0.0.1:3001')
|
||||
.post('/api/webhook/ombi')
|
||||
.reply(500, { error: 'Internal server error' });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
@@ -1029,4 +1035,26 @@ describe('POST /api/ombi/webhook/test', () => {
|
||||
|
||||
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
||||
});
|
||||
|
||||
it('falls back to local loopback when public URL request fails', async () => {
|
||||
nock(SOFARR_BASE)
|
||||
.post('/api/webhook/ombi')
|
||||
.replyWithError('Connection refused');
|
||||
nock('http://127.0.0.1:3001')
|
||||
.post('/api/webhook/ombi')
|
||||
.reply(200, { received: true });
|
||||
nock('https://127.0.0.1:3001')
|
||||
.post('/api/webhook/ombi')
|
||||
.reply(200, { received: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/test')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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({ 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('');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user