Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18f5bd26e | |||
| b4a9d7187b | |||
| 691d101e56 | |||
| e726fbe33f | |||
| 6f2901b08c | |||
| 4107bdf611 | |||
| a4af16064b | |||
| 52806d00dc | |||
| d6907f42d3 | |||
| aec04474be | |||
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 | |||
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 | |||
| 7226404221 | |||
| 1ee2a8044b | |||
| 86277e2059 | |||
| 0eaa54cf4a | |||
| 865cf1f57a | |||
| ff5f50cc3a | |||
| fd0dc7528d | |||
| 33b122d22b | |||
| c4e584cc3b | |||
| 35ff21a810 | |||
| 610632c4f0 | |||
| 5b3034e290 | |||
| 1535a5725a | |||
| 95bd703b26 | |||
| 8fb00843ef | |||
| d2ac7731ca | |||
| 6f6aa5b967 | |||
| 5390bbf615 |
+10
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
|||||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||||
SOFARR_BASE_URL=https://your-sofarr-url
|
SOFARR_BASE_URL=https://your-sofarr-url
|
||||||
|
|
||||||
|
# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking)
|
||||||
|
# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL.
|
||||||
|
# Useful if those services reside in the same local network/docker container setup and
|
||||||
|
# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s).
|
||||||
|
# Example: http://sofarr:3001 or http://192.168.1.50:3001
|
||||||
|
# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001
|
||||||
|
|
||||||
# --- Webhook Polling Optimization (Phase 5) ---
|
# --- Webhook Polling Optimization (Phase 5) ---
|
||||||
|
|
||||||
# Minutes of silence after which the poller falls back to a full poll
|
# Minutes of silence after which the poller falls back to a full poll
|
||||||
@@ -162,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
OMBI_URL=https://ombi.example.com
|
OMBI_URL=https://ombi.example.com
|
||||||
OMBI_API_KEY=your-ombi-api-key-here
|
OMBI_API_KEY=your-ombi-api-key-here
|
||||||
|
# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook
|
||||||
|
# to resolve the race condition where Ombi fires the webhook before committing to its database.
|
||||||
|
# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NOTES
|
# NOTES
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ on:
|
|||||||
- 'release/**'
|
- 'release/**'
|
||||||
- 'develop*'
|
- 'develop*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -23,23 +27,17 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building develop image tags: ${TAGS}"
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
# Primary registry tags
|
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
|
||||||
|
|
||||||
# Gitea package registry tags
|
# Gitea package registry tags
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building release image tags: ${TAGS}"
|
echo "Building release image tags: ${TAGS}"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**", "!release/**"]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
audit:
|
audit:
|
||||||
|
|||||||
+110
@@ -4,6 +4,116 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||||
|
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||||
|
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||||
|
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||||
|
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||||
|
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
|
||||||
|
|
||||||
|
## [1.7.28] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
|
||||||
|
|
||||||
|
## [1.7.26] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties.
|
||||||
|
- `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level.
|
||||||
|
- `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent.
|
||||||
|
- Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure.
|
||||||
|
|
||||||
|
## [1.7.24] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Gitea Actions Prioritization** — Optimized CI/CD workflow pipeline executions to prioritize critical `build-image` Docker compilation runs. Redundant security audits, tests, coverage generation, and RAML packages are now bypassed on `release/**` branches (which have already passed validation during development on `develop`).
|
||||||
|
- **Workflow Concurrency Controls** — Configured active concurrency groups with `cancel-in-progress: true` inside both `ci.yml` and `build-image.yml` pipelines, ensuring obsolete running jobs are aborted instantly when newer commits are pushed.
|
||||||
|
|
||||||
|
## [1.7.23] - 2026-05-27
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
|
||||||
|
- **Request Card Link Alignment (Issue #55)** — Aligned deep links on request cards (Ombi link and administrator Sonarr/Radarr deep links) with the elegant, inline `.service-icon` styling used across the downloads and history dashboard cards. Replaced bulky button elements with clean hoverable icons in a shared `.service-icons-container`, maintaining strict administrator-only visibility for *arr links.
|
||||||
|
|
||||||
|
## [1.7.22] - 2026-05-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage.
|
||||||
|
- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures.
|
||||||
|
- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists.
|
||||||
|
- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`.
|
||||||
|
- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database.
|
||||||
|
- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`).
|
||||||
|
- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.21] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior.
|
||||||
|
- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.20] - 2026-05-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.19] - 2026-05-25
|
## [1.7.19] - 2026-05-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copy application source owned by root (read-only at runtime)
|
# Copy application source owned by root (read-only at runtime)
|
||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
COPY --from=client-build --chown=root:root /app/public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+99
-19
@@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.user || request.User ||
|
||||||
return request.requestedUser.alias ||
|
request.requestedBy || request.RequestedBy ||
|
||||||
request.requestedUser.userAlias ||
|
request.ombiUser || request.OmbiUser ||
|
||||||
request.requestedUser.userName ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
request.requestedUser.normalizedUserName ||
|
|
||||||
request.requestedByAlias || '';
|
// If userSource is an object, extract key fields
|
||||||
|
if (userSource && typeof userSource === 'object') {
|
||||||
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
// If userSource is a string
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRequests() {
|
export function renderRequests() {
|
||||||
@@ -111,11 +146,39 @@ function createRequestCard(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const username = extractRequestedUser(request);
|
const username = extractRequestedUser(request);
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = document.createElement('span');
|
|
||||||
user.className = 'request-user';
|
|
||||||
user.textContent = `Requested by: ${username}`;
|
user.textContent = `Requested by: ${username}`;
|
||||||
meta.appendChild(user);
|
} else {
|
||||||
|
user.textContent = 'Requested by: Unknown (Ombi)';
|
||||||
|
user.title = 'No user information received from Ombi';
|
||||||
|
user.style.cursor = 'help';
|
||||||
|
user.style.textDecoration = 'underline dotted';
|
||||||
|
}
|
||||||
|
meta.appendChild(user);
|
||||||
|
|
||||||
|
const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null;
|
||||||
|
const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate;
|
||||||
|
if (dateStr) {
|
||||||
|
const requestDate = document.createElement('span');
|
||||||
|
requestDate.className = 'request-date';
|
||||||
|
try {
|
||||||
|
const dateObj = new Date(dateStr);
|
||||||
|
if (!isNaN(dateObj.getTime())) {
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(dateObj.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(dateObj.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
|
||||||
|
requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
requestDate.textContent = `Date: ${dateStr}`;
|
||||||
|
}
|
||||||
|
meta.appendChild(requestDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.quality) {
|
if (request.quality) {
|
||||||
@@ -128,25 +191,42 @@ function createRequestCard(request) {
|
|||||||
content.appendChild(title);
|
content.appendChild(title);
|
||||||
content.appendChild(meta);
|
content.appendChild(meta);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'request-actions';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
if (state.ombiBaseUrl && request.theMovieDbId) {
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'request-link ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`;
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
|
||||||
ombiLink.target = '_blank';
|
ombiLink.target = '_blank';
|
||||||
ombiLink.title = 'View in Ombi';
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
const ombiIcon = document.createElement('img');
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.className = 'service-icon ombi';
|
||||||
ombiIcon.src = '/images/ombi.svg';
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
ombiIcon.alt = 'Ombi';
|
ombiIcon.alt = 'Ombi';
|
||||||
ombiIcon.className = 'request-icon';
|
|
||||||
|
|
||||||
ombiLink.appendChild(ombiIcon);
|
ombiLink.appendChild(ombiIcon);
|
||||||
actions.appendChild(ombiLink);
|
actions.appendChild(ombiLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isAdmin && request.arrLink) {
|
||||||
|
const arrLink = document.createElement('a');
|
||||||
|
arrLink.className = `${request.arrType}-link`;
|
||||||
|
arrLink.href = request.arrLink;
|
||||||
|
arrLink.target = '_blank';
|
||||||
|
arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`;
|
||||||
|
|
||||||
|
const arrIcon = document.createElement('img');
|
||||||
|
arrIcon.className = `service-icon ${request.arrType}`;
|
||||||
|
arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg';
|
||||||
|
arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr';
|
||||||
|
|
||||||
|
arrLink.appendChild(arrIcon);
|
||||||
|
actions.appendChild(arrLink);
|
||||||
|
}
|
||||||
|
|
||||||
card.appendChild(typeIcon);
|
card.appendChild(typeIcon);
|
||||||
card.appendChild(content);
|
card.appendChild(content);
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|||||||
+29
-10
@@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js';
|
|||||||
|
|
||||||
// Apply saved theme immediately on load
|
// Apply saved theme immediately on load
|
||||||
(function applyTheme() {
|
(function applyTheme() {
|
||||||
const theme = getTheme();
|
const theme = getTheme() || 'light';
|
||||||
if (theme) {
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export function initThemeSwitcher() {
|
export function initThemeSwitcher() {
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
if (!themeToggle) return;
|
const currentTheme = getTheme() || 'light';
|
||||||
|
|
||||||
themeToggle.addEventListener('click', () => {
|
// Set initial active state on buttons
|
||||||
const currentTheme = getTheme();
|
themeButtons.forEach(btn => {
|
||||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
if (btn.getAttribute('data-theme') === currentTheme) {
|
||||||
setTheme(newTheme);
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const theme = btn.getAttribute('data-theme');
|
||||||
|
if (theme) {
|
||||||
|
setTheme(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme) {
|
export function setTheme(theme) {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
saveTheme(theme);
|
saveTheme(theme);
|
||||||
|
|
||||||
|
// Sync button active classes if elements are present on the page
|
||||||
|
const themeButtons = document.querySelectorAll('.theme-btn');
|
||||||
|
themeButtons.forEach(btn => {
|
||||||
|
if (btn.getAttribute('data-theme') === theme) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ export function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-24
@@ -1,28 +1,40 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
build: {
|
// Load env variables from root directory to match backend TLS configuration
|
||||||
outDir: '../public',
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
emptyOutDir: false,
|
|
||||||
rollupOptions: {
|
const port = env.PORT || 3001;
|
||||||
input: {
|
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
main: './src/main.js'
|
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||||
},
|
|
||||||
output: {
|
return {
|
||||||
entryFileNames: 'app.js',
|
build: {
|
||||||
chunkFileNames: '[name].js',
|
outDir: '../public',
|
||||||
assetFileNames: '[name][extname]'
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: './src/main.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'app.js',
|
||||||
|
chunkFileNames: '[name].js',
|
||||||
|
assetFileNames: '[name][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true, // Listen on all network interfaces
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: target,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false // Allow self-signed certificate in development
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
server: {
|
});
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.19",
|
"version": "1.7.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.19",
|
"version": "1.7.31",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.19",
|
"version": "1.7.31",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
|
"build": "npm run build --prefix client",
|
||||||
"install:all": "npm install",
|
"install:all": "npm install",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
+22
-20
File diff suppressed because one or more lines are too long
+40
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.19"
|
* example: "1.7.31"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
@@ -232,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files — served before API routes
|
||||||
|
// index.html is served manually so we can inject the CSP nonce
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../public');
|
||||||
|
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
// Serve all static assets (js, css, images, icons) except index.html.
|
||||||
|
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
||||||
|
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
||||||
|
app.use(express.static(PUBLIC_DIR, {
|
||||||
|
index: false,
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
|
function serveIndex(req, res) {
|
||||||
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
|
const nonce = res.locals.cspNonce;
|
||||||
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
|
const patched = html
|
||||||
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(patched);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
|
app.get('*', serveIndex);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users from Ombi
|
||||||
|
* @returns {Promise<Array>} Array of user objects
|
||||||
|
*/
|
||||||
|
async getUsers() {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = OmbiClient;
|
module.exports = OmbiClient;
|
||||||
|
|||||||
+113
-10
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
this.cache = {
|
this.cache = {
|
||||||
movieRequests: [],
|
movieRequests: [],
|
||||||
tvRequests: [],
|
tvRequests: [],
|
||||||
|
users: [],
|
||||||
movieMap: new Map(), // tmdbId -> request
|
movieMap: new Map(), // tmdbId -> request
|
||||||
tvMap: new Map(), // tvdbId -> request
|
tvMap: new Map(), // tvdbId -> request
|
||||||
|
userMap: new Map(), // id -> user
|
||||||
lastFetch: 0,
|
lastFetch: 0,
|
||||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||||
};
|
};
|
||||||
@@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
try {
|
try {
|
||||||
logToFile('[OmbiRetriever] Refreshing cache');
|
logToFile('[OmbiRetriever] Refreshing cache');
|
||||||
|
|
||||||
// Fetch requests in parallel
|
// Fetch requests and users in parallel
|
||||||
const [movieRequests, tvRequests] = await Promise.all([
|
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||||
this.client.getMovieRequests(),
|
this.client.getMovieRequests(),
|
||||||
this.client.getTvRequests()
|
this.client.getTvRequests(),
|
||||||
|
this.client.getUsers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cache.movieRequests = movieRequests;
|
this.cache.movieRequests = movieRequests;
|
||||||
this.cache.tvRequests = tvRequests;
|
this.cache.tvRequests = tvRequests;
|
||||||
|
this.cache.users = users;
|
||||||
this.cache.lastFetch = Date.now();
|
this.cache.lastFetch = Date.now();
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
this.cache.movieMap.clear();
|
this.cache.movieMap.clear();
|
||||||
this.cache.tvMap.clear();
|
this.cache.tvMap.clear();
|
||||||
|
this.cache.userMap.clear();
|
||||||
|
|
||||||
|
// Build user map (id -> user)
|
||||||
|
if (Array.isArray(users)) {
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user && user.id) {
|
||||||
|
this.cache.userMap.set(user.id, user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Build movie map (tmdbId -> request)
|
// Build movie map (tmdbId -> request)
|
||||||
movieRequests.forEach(request => {
|
movieRequests.forEach(request => {
|
||||||
@@ -133,13 +147,102 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||||
// Don't throw error, continue with stale cache if available
|
// Don't throw error, continue with stale cache if available
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a single request using the userMap cache
|
||||||
|
* @param {Object} req - The request object
|
||||||
|
* @returns {Object} Hydrated request object
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequest(req) {
|
||||||
|
if (!req) return req;
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
id: cachedUser.id,
|
||||||
|
userName: cachedUser.userName,
|
||||||
|
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||||
|
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||||
|
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
result = {
|
||||||
|
...req,
|
||||||
|
requestedUser: hydratedUser,
|
||||||
|
RequestedUser: hydratedUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||||
|
* @param {Array} requests - Array of request objects
|
||||||
|
* @returns {Array} Array of hydrated request objects
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_hydrateRequests(requests) {
|
||||||
|
if (!Array.isArray(requests)) return [];
|
||||||
|
return requests.map(req => this._hydrateRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all movie requests
|
* Get all movie requests
|
||||||
* @param {boolean} force - Whether to force refresh from API
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
@@ -147,7 +250,7 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getMovieRequests(force = false) {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
await this.refreshCache(force);
|
||||||
return this.cache.movieRequests;
|
return this._hydrateRequests(this.cache.movieRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +260,7 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getTvRequests(force = false) {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
await this.refreshCache(force);
|
||||||
return this.cache.tvRequests;
|
return this._hydrateRequests(this.cache.tvRequests);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,12 +274,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TMDB ID first
|
// Try TMDB ID first
|
||||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||||
return this.cache.movieMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try IMDB ID as fallback
|
// Try IMDB ID as fallback
|
||||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||||
return this.cache.movieMap.get(imdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -193,12 +296,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TVDB ID first
|
// Try TVDB ID first
|
||||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||||
return this.cache.tvMap.get(tvdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try TMDB ID as fallback
|
// Try TMDB ID as fallback
|
||||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||||
return this.cache.tvMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+2
-287
@@ -82,20 +82,9 @@ console.error = function(...args) {
|
|||||||
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
|
||||||
const radarrRoutes = require('./routes/radarr');
|
|
||||||
const embyRoutes = require('./routes/emby');
|
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
|
||||||
const statusRoutes = require('./routes/status');
|
|
||||||
const historyRoutes = require('./routes/history');
|
|
||||||
const authRoutes = require('./routes/auth');
|
|
||||||
const webhookRoutes = require('./routes/webhook');
|
|
||||||
const ombiRoutes = require('./routes/ombi');
|
|
||||||
const debugRoutes = require('./routes/debug');
|
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
|
const { createApp } = require('./app');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Startup environment validation
|
// Startup environment validation
|
||||||
@@ -117,284 +106,10 @@ if (process.env.EMBY_URL) {
|
|||||||
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Load OpenAPI spec from YAML
|
|
||||||
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
|
|
||||||
|
|
||||||
// Configure swagger-jsdoc to merge JSDoc comments from route files
|
|
||||||
const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
...openapiSpec,
|
|
||||||
openapi: '3.1.0'
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(__dirname, 'routes/*.js'),
|
|
||||||
path.join(__dirname, 'index.js')
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|
||||||
|
|
||||||
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
|
||||||
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
|
||||||
// req.secure is true when the upstream TLS is terminated by the proxy.
|
|
||||||
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (process.env.TRUST_PROXY) {
|
|
||||||
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
|
|
||||||
? parseInt(process.env.TRUST_PROXY, 10)
|
|
||||||
: process.env.TRUST_PROXY;
|
|
||||||
app.set('trust proxy', trustValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helmet v7 — security response headers
|
|
||||||
// CSP uses a per-request nonce injected into index.html so inline scripts
|
|
||||||
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
// Generate a fresh nonce for every request
|
|
||||||
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
|
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
||||||
fontSrc: ["'self'", 'data:'],
|
|
||||||
connectSrc: ["'self'"],
|
|
||||||
objectSrc: ["'none'"],
|
|
||||||
baseUri: ["'self'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
formAction: ["'self'"],
|
|
||||||
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hsts: {
|
|
||||||
maxAge: 31536000, // 1 year
|
|
||||||
includeSubDomains: true,
|
|
||||||
preload: true
|
|
||||||
},
|
|
||||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
||||||
crossOriginEmbedderPolicy: false // not needed for this SPA
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Permissions-Policy — disable powerful browser features not needed by the app
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.setHeader(
|
|
||||||
'Permissions-Policy',
|
|
||||||
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
|
|
||||||
);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// General API rate limiter — applies to all /api/* routes
|
|
||||||
// More specific limiters (e.g. login) apply on top of this.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const apiLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
|
||||||
message: { error: 'Too many requests, please try again later' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body parsing & cookies
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use(cookieParser(cookieSecret || undefined));
|
|
||||||
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Health / readiness endpoints (no auth, no rate-limit)
|
|
||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /health:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Health check
|
|
||||||
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is healthy
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ok"
|
|
||||||
* uptime:
|
|
||||||
* type: number
|
|
||||||
* description: Server uptime in seconds
|
|
||||||
* example: 3600.5
|
|
||||||
* version:
|
|
||||||
* type: string
|
|
||||||
* description: sofarr version
|
|
||||||
* example: "1.7.19"
|
|
||||||
*/
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /ready:
|
|
||||||
* get:
|
|
||||||
* tags: [Health]
|
|
||||||
* summary: Readiness check
|
|
||||||
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
|
|
||||||
* security: []
|
|
||||||
* responses:
|
|
||||||
* '200':
|
|
||||||
* description: Server is ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "ready"
|
|
||||||
* '503':
|
|
||||||
* description: Server not ready
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* status:
|
|
||||||
* type: string
|
|
||||||
* example: "not ready"
|
|
||||||
* reason:
|
|
||||||
* type: string
|
|
||||||
* example: "EMBY_URL not configured"
|
|
||||||
*/
|
|
||||||
app.get('/ready', (req, res) => {
|
|
||||||
// Confirm critical config is present
|
|
||||||
const ready = !!(process.env.EMBY_URL);
|
|
||||||
if (ready) {
|
|
||||||
res.json({ status: 'ready' });
|
|
||||||
} else {
|
|
||||||
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Swagger UI - publicly accessible API documentation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
|
|
||||||
customSiteTitle: 'sofarr API Documentation',
|
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
|
||||||
customJs: [
|
|
||||||
'/swagger-auth-banner.js'
|
|
||||||
],
|
|
||||||
swaggerOptions: {
|
|
||||||
url: '/api/swagger.json'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve the raw OpenAPI spec as JSON with dynamic server URL
|
|
||||||
app.get('/api/swagger.json', (req, res) => {
|
|
||||||
// Clone the spec to avoid modifying the original
|
|
||||||
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
|
|
||||||
|
|
||||||
// Replace the server URL with the current request's origin
|
|
||||||
if (specCopy.servers && specCopy.servers.length > 0) {
|
|
||||||
const protocol = req.protocol;
|
|
||||||
const host = req.get('host');
|
|
||||||
specCopy.servers[0].url = `${protocol}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(specCopy);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static files — served before API routes
|
|
||||||
// index.html is served manually so we can inject the CSP nonce
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const PUBLIC_DIR = path.join(__dirname, '../public');
|
|
||||||
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
|
|
||||||
|
|
||||||
// Serve all static assets (js, css, images, icons) except index.html.
|
|
||||||
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
|
|
||||||
// avoids re-downloading unchanged files; only a deploy changes the ETag).
|
|
||||||
app.use(express.static(PUBLIC_DIR, {
|
|
||||||
index: false,
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve index.html with CSP nonce injected into <script> tags
|
|
||||||
function serveIndex(req, res) {
|
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
|
||||||
const nonce = res.locals.cspNonce;
|
|
||||||
// Only inject nonce into <script> tags — style-src 'self' already permits
|
|
||||||
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
|
||||||
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
|
||||||
// the old nonce which no longer matches the per-request CSP header).
|
|
||||||
const patched = html
|
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.send(patched);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
|
|
||||||
// CSRF protection applies to all state-changing /api/* requests except
|
|
||||||
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
app.use('/api', apiLimiter);
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/webhook', webhookRoutes);
|
|
||||||
app.use('/api/debug', debugRoutes);
|
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
|
||||||
app.use('/api', verifyCsrf);
|
|
||||||
app.use('/api/sabnzbd', sabnzbdRoutes);
|
|
||||||
app.use('/api/sonarr', sonarrRoutes);
|
|
||||||
app.use('/api/radarr', radarrRoutes);
|
|
||||||
app.use('/api/emby', embyRoutes);
|
|
||||||
app.use('/api/ombi', ombiRoutes);
|
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
|
||||||
app.use('/api/status', statusRoutes);
|
|
||||||
app.use('/api/history', historyRoutes);
|
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
|
||||||
app.get('*', serveIndex);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global error handler — never leak stack traces to clients
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error('[Server] Unhandled error:', err.message);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// TLS / HTTPS support
|
// TLS / HTTPS support
|
||||||
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.19
|
version: 1.7.31
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
|
|||||||
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { canBlocklist } = require('../services/DownloadAssembler');
|
const { canBlocklist } = require('../services/DownloadAssembler');
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
ombiBaseUrl
|
ombiBaseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||||
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
||||||
id: c.getInstanceId(),
|
id: c.getInstanceId(),
|
||||||
@@ -525,8 +533,14 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||||
|
|
||||||
|
|
||||||
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
|
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
|
||||||
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
|
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
|
||||||
|
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
const ombiRequestsFiltered = {
|
const ombiRequestsFiltered = {
|
||||||
movie: filteredOmbiMovieRequests,
|
movie: filteredOmbiMovieRequests,
|
||||||
|
|||||||
+73
-22
@@ -2,9 +2,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -126,6 +126,11 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Admin only: add Sonarr/Radarr lookup links
|
||||||
|
if (isAdmin) {
|
||||||
|
await decorateRequestsWithArrLinks(allRequests, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
let types = req.query.type;
|
let types = req.query.type;
|
||||||
let statuses = req.query.status;
|
let statuses = req.query.status;
|
||||||
@@ -205,10 +210,10 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -221,7 +226,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||||
|
|
||||||
// Call Ombi API to register webhook
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
@@ -462,10 +467,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -478,25 +483,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
// Simulate a test webhook event
|
// Simulate a test webhook event
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
await axios.post(webhookUrl, {
|
try {
|
||||||
notificationType: 'RequestAvailable',
|
await axios.post(webhookUrl, {
|
||||||
requestId: 0,
|
notificationType: 'RequestAvailable',
|
||||||
requestedUser: 'test',
|
requestId: 0,
|
||||||
title: 'Test Request',
|
requestedUser: 'test',
|
||||||
type: 'Movie',
|
title: 'Test Request',
|
||||||
requestStatus: 'Pending'
|
type: 'Movie',
|
||||||
}, {
|
requestStatus: 'Pending'
|
||||||
headers: {
|
}, {
|
||||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||||
|
} catch (error) {
|
||||||
|
logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
let useHttps = false;
|
||||||
|
if (tlsEnabled) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const certsDir = path.join(__dirname, '../../certs');
|
||||||
|
const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt');
|
||||||
|
const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key');
|
||||||
|
try {
|
||||||
|
fs.readFileSync(tlsCertPath);
|
||||||
|
fs.readFileSync(tlsKeyPath);
|
||||||
|
useHttps = true;
|
||||||
|
} catch {
|
||||||
|
useHttps = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
|
||||||
|
const https = require('https');
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post(localUrl, {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 0,
|
||||||
|
requestedUser: 'test',
|
||||||
|
title: 'Test Request',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending'
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Sofarr-Webhook-Secret': webhookSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
httpsAgent: useHttps ? agent : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Radarr instance (for notification proxy routes)
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
function getFirstRadarrInstance() {
|
function getFirstRadarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Radarr not configured' });
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
// Helper to get first Sonarr instance (for notification proxy routes)
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
function getFirstSonarrInstance() {
|
function getFirstSonarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function validateWebhookSecret(req) {
|
|||||||
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
@@ -259,9 +259,66 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const ombiInstances = getOmbiInstances();
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
if (affectsOmbi) {
|
if (affectsOmbi) {
|
||||||
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
||||||
|
await new Promise(r => setTimeout(r, initialDelay));
|
||||||
|
|
||||||
|
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
||||||
|
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
||||||
|
|
||||||
|
let ombiRequests = { movie: [], tv: [] };
|
||||||
|
let foundAndValid = false;
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelayMs = 1500;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 1) {
|
||||||
|
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in movie or tv lists
|
||||||
|
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
||||||
|
// Also check both if mediaType not specified
|
||||||
|
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
const user = extractRequestedUser(targetReq);
|
||||||
|
if (user) {
|
||||||
|
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
||||||
|
foundAndValid = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundAndValid && requestId) {
|
||||||
|
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
||||||
|
// Try to log the raw target request if we found one
|
||||||
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
||||||
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
||||||
|
if (targetReq) {
|
||||||
|
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
||||||
|
} else {
|
||||||
|
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||||
}
|
}
|
||||||
@@ -777,7 +834,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background cache refresh + SSE broadcast (fire-and-forget)
|
// Background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
processWebhookEvent('ombi', eventType).catch(err => {
|
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
||||||
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
|
|||||||
return process.env.SOFARR_BASE_URL || '';
|
return process.env.SOFARR_BASE_URL || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSofarrWebhookBaseUrl() {
|
||||||
|
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -140,6 +144,7 @@ module.exports = {
|
|||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
getWebhookSecret,
|
getWebhookSecret,
|
||||||
getSofarrBaseUrl,
|
getSofarrBaseUrl,
|
||||||
|
getSofarrWebhookBaseUrl,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ function getRequestStatus(request) {
|
|||||||
if (request.denied) return 'denied';
|
if (request.denied) return 'denied';
|
||||||
if (request.approved) return 'approved';
|
if (request.approved) return 'approved';
|
||||||
if (request.requested) return 'pending';
|
if (request.requested) return 'pending';
|
||||||
|
|
||||||
|
// Ombi TV requests store status flags inside childRequests
|
||||||
|
if (Array.isArray(request.childRequests) && request.childRequests.length > 0) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.available) return 'available';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.denied) return 'denied';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.approved) return 'approved';
|
||||||
|
}
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
if (child && child.requested) return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+206
-13
@@ -5,6 +5,8 @@
|
|||||||
* not a string, so we need to extract the username from the object.
|
* not a string, so we need to extract the username from the object.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the username from an Ombi request object.
|
* Extracts the username from an Ombi request object.
|
||||||
* Handles both the OmbiUser object format and legacy string format.
|
* Handles both the OmbiUser object format and legacy string format.
|
||||||
@@ -15,19 +17,57 @@
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
const requestedUser = request.requestedUser || request.RequestedUser;
|
// Try to locate a user object or string from various fields common to Ombi Movies and TV shows
|
||||||
|
const userSource = request.requestedUser || request.RequestedUser ||
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
request.user || request.User ||
|
||||||
if (requestedUser && typeof requestedUser === 'object') {
|
request.requestedBy || request.RequestedBy ||
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
request.ombiUser || request.OmbiUser ||
|
||||||
return requestedUser.alias || requestedUser.Alias ||
|
request.requestedByUser || request.RequestedByUser;
|
||||||
requestedUser.userAlias || requestedUser.UserAlias ||
|
|
||||||
requestedUser.userName || requestedUser.UserName ||
|
// If userSource is an object, extract key fields
|
||||||
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
if (userSource && typeof userSource === 'object') {
|
||||||
request.requestedByAlias || request.RequestedByAlias || '';
|
const username = userSource.alias || userSource.Alias ||
|
||||||
|
userSource.userAlias || userSource.UserAlias ||
|
||||||
|
userSource.userName || userSource.UserName ||
|
||||||
|
userSource.normalizedUserName || userSource.NormalizedUserName ||
|
||||||
|
userSource.displayName || userSource.DisplayName ||
|
||||||
|
userSource.email || userSource.Email;
|
||||||
|
if (username) return username;
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
|
||||||
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
// If userSource is a string and not an empty object/array
|
||||||
|
if (userSource && typeof userSource === 'string') {
|
||||||
|
return userSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks on the request root level
|
||||||
|
const rootFallback = request.requestedByAlias || request.RequestedByAlias ||
|
||||||
|
request.requestedByUsername || request.RequestedByUsername ||
|
||||||
|
request.requester || request.Requester ||
|
||||||
|
request.requestedByEmail || request.RequestedByEmail;
|
||||||
|
if (rootFallback) return rootFallback;
|
||||||
|
|
||||||
|
// Check seasons / childRequests nested arrays (common for Ombi TV show requests)
|
||||||
|
if (Array.isArray(request.seasons)) {
|
||||||
|
for (const season of request.seasons) {
|
||||||
|
const seasonUser = extractRequestedUser(season);
|
||||||
|
if (seasonUser) return seasonUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(request.childRequests)) {
|
||||||
|
for (const child of request.childRequests) {
|
||||||
|
const childUser = extractRequestedUser(child);
|
||||||
|
if (childUser) return childUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add warning log when user extraction returns empty for non-empty requests
|
||||||
|
if (Object.keys(request).length > 0 && !request.notificationType) {
|
||||||
|
logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRequestsByUser(requests, username, showAll) {
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
@@ -40,7 +80,160 @@ function filterRequestsByUser(requests, username, showAll) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(requests)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
requests.forEach(req => {
|
||||||
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
|
// Fallback to checking for TV specific IDs.
|
||||||
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
|
if (!tvdbId) return;
|
||||||
|
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
req.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
|
||||||
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
req.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
|
||||||
|
if (!isAdmin || !Array.isArray(downloads)) return;
|
||||||
|
|
||||||
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
|
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
|
||||||
|
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
|
||||||
|
|
||||||
|
const [sonarrData, radarrData] = await Promise.all([
|
||||||
|
Promise.all(sonarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, series: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, series: [] };
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Promise.all(radarrRetrievers.map(async r => {
|
||||||
|
try {
|
||||||
|
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': r.apiKey }
|
||||||
|
});
|
||||||
|
return { instance: r, movies: response.data || [] };
|
||||||
|
} catch {
|
||||||
|
return { instance: r, movies: [] };
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
downloads.forEach(dl => {
|
||||||
|
// Determine if it's TV (series) or Movie
|
||||||
|
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// Look for a match in Sonarr instances
|
||||||
|
for (const instData of sonarrData) {
|
||||||
|
const match = instData.series.find(s => {
|
||||||
|
if (!s) return false;
|
||||||
|
// Match by database series ID if the instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to seriesName matching
|
||||||
|
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'sonarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
|
||||||
|
// Look for a match in Radarr instances
|
||||||
|
for (const instData of radarrData) {
|
||||||
|
const match = instData.movies.find(m => {
|
||||||
|
if (!m) return false;
|
||||||
|
// Match by database movie ID if instance matches
|
||||||
|
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
|
||||||
|
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback to movieName matching
|
||||||
|
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match && match.titleSlug) {
|
||||||
|
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
|
||||||
|
dl.arrType = 'radarr';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
extractRequestedUser,
|
extractRequestedUser,
|
||||||
filterRequestsByUser
|
filterRequestsByUser,
|
||||||
|
decorateRequestsWithArrLinks,
|
||||||
|
decorateDownloadsWithArrLinks
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
|||||||
expect(result.childNodes.length).toBe(0);
|
expect(result.childNodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('createDownloadCard rendering details', () => {
|
||||||
|
let originalState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalState = { ...state };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset global state
|
||||||
|
Object.assign(state, originalState);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createClientLogo and fallbacks', () => {
|
||||||
|
it('renders client logo img tag when client is configured', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceName: 'Qbit Main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
|
||||||
|
const img = wrapper.querySelector('img.download-client-logo');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||||
|
expect(img.alt).toBe('Qbit Main icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to character avatar text on img load error', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'transmission'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
|
||||||
|
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||||
|
img.onerror();
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||||
|
expect(wrapper.textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createServiceIcons deep-linking', () => {
|
||||||
|
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||||
|
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
ombiLink: 'https://ombi.test/request/42',
|
||||||
|
ombiTooltip: 'View on Ombi'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const ombiLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(ombiLinkEl).toBeTruthy();
|
||||||
|
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||||
|
|
||||||
|
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('View on Ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Sonarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Radarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Blade Runner 2049',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrType: 'radarr',
|
||||||
|
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-movie a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||||
|
state.isAdmin = false; // Non-admin
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
|
|||||||
expect(dl.canBlocklist).toBe(true);
|
expect(dl.canBlocklist).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
|
||||||
|
it('decorates active series downloads with Sonarr links for administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
// Seed cache: queue record exists and matches SABnzbd slot
|
||||||
|
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr /api/v3/movie response
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const downloads = res.body.downloads;
|
||||||
|
const dl = downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
|
||||||
|
expect(dl.arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1093,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
|||||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||||
|
const { cookies } = await loginAs(appInstance);
|
||||||
|
const res = await request(appInstance)
|
||||||
|
.get('/api/dashboard/stream')
|
||||||
|
.query({ testClose: 'true' })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('data:');
|
||||||
|
|
||||||
|
const dataStr = text.substring(text.indexOf('{'));
|
||||||
|
const data = JSON.parse(dataStr.trim());
|
||||||
|
|
||||||
|
// Payload Contract Validation
|
||||||
|
expect(data).toHaveProperty('user');
|
||||||
|
expect(data).toHaveProperty('isAdmin');
|
||||||
|
expect(data).toHaveProperty('downloads');
|
||||||
|
expect(data).toHaveProperty('downloadClients');
|
||||||
|
expect(data).toHaveProperty('ombiRequests');
|
||||||
|
expect(data).toHaveProperty('ombiBaseUrl');
|
||||||
|
|
||||||
|
expect(Array.isArray(data.downloads)).toBe(true);
|
||||||
|
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 1. Get the route handler from the dashboard router stack
|
||||||
|
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||||
|
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||||
|
// Get the final handler (after requireAuth middleware)
|
||||||
|
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
// 2. Setup mock req and res
|
||||||
|
const mockUser = { name: 'Alice', isAdmin: false };
|
||||||
|
const reqOnCallbacks = {};
|
||||||
|
const mockReq = {
|
||||||
|
user: mockUser,
|
||||||
|
query: { showAll: 'false', testClose: 'false' },
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
reqOnCallbacks[event] = cb;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const resWrites = [];
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
flushHeaders: vi.fn(),
|
||||||
|
write: vi.fn((data) => {
|
||||||
|
resWrites.push(data);
|
||||||
|
}),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Call the handler
|
||||||
|
await streamHandler(mockReq, mockRes);
|
||||||
|
|
||||||
|
// Initial payload should be written
|
||||||
|
expect(resWrites.length).toBeGreaterThan(0);
|
||||||
|
expect(resWrites[0]).toContain('data:');
|
||||||
|
|
||||||
|
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
|
||||||
|
// Check that heartbeat was written
|
||||||
|
expect(resWrites).toContain(': heartbeat\n\n');
|
||||||
|
|
||||||
|
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||||
|
expect(reqOnCallbacks['close']).toBeDefined();
|
||||||
|
reqOnCallbacks['close']();
|
||||||
|
|
||||||
|
// Check that advancing time again does NOT write another heartbeat
|
||||||
|
const beforeLength = resWrites.length;
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
@@ -1014,10 +1056,16 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
expect(webhookScope.isDone()).toBe(true);
|
expect(webhookScope.isDone()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles webhook send errors gracefully', async () => {
|
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
|
||||||
nock(SOFARR_BASE)
|
nock(SOFARR_BASE)
|
||||||
.post('/api/webhook/ombi')
|
.post('/api/webhook/ombi')
|
||||||
.reply(500, { error: 'Internal server error' });
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
|
||||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
@@ -1029,4 +1077,26 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
|
|
||||||
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to local loopback when public URL request fails', async () => {
|
||||||
|
nock(SOFARR_BASE)
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.replyWithError('Connection refused');
|
||||||
|
nock('http://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
nock('https://127.0.0.1:3001')
|
||||||
|
.post('/api/webhook/ombi')
|
||||||
|
.reply(200, { received: true });
|
||||||
|
|
||||||
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ombi/webhook/test')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||||
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||||
|
|
||||||
|
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||||
|
const RADARR_BASE = 'https://radarr-decor.test';
|
||||||
|
|
||||||
|
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
// Reset the singleton retrievers registry so we can inject our test instances
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// Configure test environment variables for retrievers
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||||
|
// Mock Sonarr series query
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr movie query (empty)
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrSeriesId: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||||
|
expect(downloads[0].arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||||
|
// Mock Sonarr series query (empty)
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
// Mock Radarr movie query with matching ID
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'Blade.Runner.2049.2017.1080p',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrInstanceUrl: RADARR_BASE,
|
||||||
|
arrContentId: 99
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||||
|
expect(downloads[0].arrType).toBe('radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips decoration entirely when isAdmin is false', async () => {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// No nocks are set up, so any HTTP calls would throw or error
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, false);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty downloads array gracefully', async () => {
|
||||||
|
// No mock setups needed, should complete without throwing
|
||||||
|
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||||
|
// Mock Sonarr series query throwing connection error
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
// Mock Radarr movie query throwing timeout error
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No links decorated since the fetch failed
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let originalSkipRateLimit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Save current rate limiting skip flag
|
||||||
|
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||||
|
// Explicitly delete it before loading the app so rate limiters are active
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
|
||||||
|
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||||
|
const appModule = await import('../../server/app.js');
|
||||||
|
const createApp = appModule.createApp;
|
||||||
|
|
||||||
|
// Create a new app instance with rate limiting enabled
|
||||||
|
app = createApp({ skipRateLimits: false });
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore rate limit skip flag
|
||||||
|
if (originalSkipRateLimit !== undefined) {
|
||||||
|
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||||
|
} else {
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||||
|
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||||
|
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||||
|
// count toward the rate limit window of 10 requests.
|
||||||
|
nock('https://emby.test')
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(401, { error: 'Unauthorized' })
|
||||||
|
.persist();
|
||||||
|
|
||||||
|
// Fire 10 rapid failed login requests (the limit is 10)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11th request must be rate limited and return 429
|
||||||
|
const limitRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(limitRes.status).toBe(429);
|
||||||
|
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getUsers', () => {
|
||||||
|
it('should return user array for successful request', async () => {
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: '1', userName: 'Gordon' },
|
||||||
|
{ id: '2', userName: 'Alice' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on API error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.reply(500, { error: 'Internal Server Error' });
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on network error', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.matchHeader('ApiKey', apiKey)
|
||||||
|
.replyWithError('Network error');
|
||||||
|
|
||||||
|
const client = new OmbiClient(baseUrl, apiKey);
|
||||||
|
const result = await client.getUsers();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -766,4 +766,70 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hydration logic', () => {
|
||||||
|
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser).toBeDefined();
|
||||||
|
expect(result[0].requestedUser.userName).toBe('Gordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('G-Man');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not overwrite non-empty requestedUser object', async () => {
|
||||||
|
const mockMovies = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Movie 1',
|
||||||
|
requestedUserId: 'gordon-id',
|
||||||
|
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockTvShows = [];
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Identity/Users')
|
||||||
|
.reply(200, mockUsers);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
const result = await retriever.getMovieRequests();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
|
||||||
|
expect(result[0].requestedUser.alias).toBe('ExistingG');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,40 @@ describe('getRequestStatus', () => {
|
|||||||
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||||
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns available from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns denied from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns approved from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending from childRequests when top-level is absent (TV)', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows priority inside childRequests: available > denied > approved > pending', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ available: true, denied: true },
|
||||||
|
{ approved: true }
|
||||||
|
]})).toBe('available');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ denied: true, approved: true },
|
||||||
|
{ requested: true }
|
||||||
|
]})).toBe('denied');
|
||||||
|
expect(getRequestStatus({ childRequests: [
|
||||||
|
{ approved: true, requested: true }
|
||||||
|
]})).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for TV request with empty childRequests', () => {
|
||||||
|
expect(getRequestStatus({ childRequests: [] })).toBe('unknown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -79,6 +79,85 @@ describe('ombiHelpers', () => {
|
|||||||
};
|
};
|
||||||
expect(extractRequestedUser(req)).toBe('');
|
expect(extractRequestedUser(req)).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns userName from nested user object', () => {
|
||||||
|
const req = { user: { userName: 'user_val' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('user_val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns alias from nested requestedBy object', () => {
|
||||||
|
const req = { requestedBy: { alias: 'req_alias' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('req_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns normalizedUserName from nested ombiUser object', () => {
|
||||||
|
const req = { ombiUser: { normalizedUserName: 'norm_ombi' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('norm_ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns userAlias from nested requestedByUser object', () => {
|
||||||
|
const req = { requestedByUser: { userAlias: 'alias_user' } };
|
||||||
|
expect(extractRequestedUser(req)).toBe('alias_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from a string source value', () => {
|
||||||
|
const req = { requestedBy: 'direct_string' };
|
||||||
|
expect(extractRequestedUser(req)).toBe('direct_string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => {
|
||||||
|
expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname');
|
||||||
|
expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val');
|
||||||
|
expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from seasons array requests', () => {
|
||||||
|
const req = {
|
||||||
|
seasons: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { alias: 'season_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('season_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests array', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ user: { userName: 'child_user' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('child_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{},
|
||||||
|
{ requestedUser: { userName: 'tv_user', alias: 'tv_alias' } }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('tv_alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively extracts user from childRequests requestedUser as string', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedUser: 'string_user' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('string_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => {
|
||||||
|
const req = {
|
||||||
|
childRequests: [
|
||||||
|
{ requestedByAlias: 'deep_alias' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(extractRequestedUser(req)).toBe('deep_alias');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterRequestsByUser', () => {
|
describe('filterRequestsByUser', () => {
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Set environment variables before requiring any modules
|
||||||
|
process.env.POLL_INTERVAL = '5000';
|
||||||
|
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||||
|
|
||||||
|
const cache = require('../../../server/utils/cache.js');
|
||||||
|
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||||
|
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||||
|
const config = require('../../../server/utils/config.js');
|
||||||
|
|
||||||
|
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||||
|
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||||
|
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||||
|
sabnzbd: [],
|
||||||
|
qbittorrent: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||||
|
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||||
|
movie: [],
|
||||||
|
tv: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||||
|
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||||
|
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||||
|
|
||||||
|
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||||
|
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||||
|
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||||
|
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: null
|
||||||
|
});
|
||||||
|
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Now require the poller
|
||||||
|
const poller = require('../../../server/utils/poller.js');
|
||||||
|
|
||||||
|
describe('Background Poller Utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-apply standard resolved values
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||||
|
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||||
|
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||||
|
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getOmbiInstancesSpy.mockReturnValue([]);
|
||||||
|
|
||||||
|
cacheSetSpy.mockImplementation(() => {});
|
||||||
|
cacheGetSpy.mockReturnValue(null);
|
||||||
|
getWebhookMetricsSpy.mockReturnValue(null);
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||||
|
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
poller.stopPoller();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Poller Core Logic', () => {
|
||||||
|
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||||
|
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||||
|
expect(poller.POLLING_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||||
|
let callbackFired = false;
|
||||||
|
const callback = () => {
|
||||||
|
callbackFired = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.onPollComplete(callback);
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(callbackFired).toBe(true);
|
||||||
|
|
||||||
|
// Clean up/Deregister callback
|
||||||
|
poller.offPollComplete(callback);
|
||||||
|
callbackFired = false;
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(callbackFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||||
|
// Stub initializeClients to delay using a promise
|
||||||
|
let resolveInit;
|
||||||
|
const delayPromise = new Promise((resolve) => {
|
||||||
|
resolveInit = resolve;
|
||||||
|
});
|
||||||
|
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||||
|
|
||||||
|
// Start the first poll (which remains pending on initializeClients)
|
||||||
|
const firstPollPromise = poller.pollAllServices();
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Trigger second poll immediately while first is in progress
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||||
|
|
||||||
|
// Resolve the delay to let the first poll finish
|
||||||
|
resolveInit();
|
||||||
|
await firstPollPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||||
|
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||||
|
|
||||||
|
// Setup error spy
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||||
|
|
||||||
|
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Webhook-Based Instance Bypassing', () => {
|
||||||
|
it('skips polling for an instance with recent active webhook events', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Verify that skips are incremented for both
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||||
|
|
||||||
|
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||||
|
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||||
|
const staleTimestamp = Date.now() - 11 * 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Should bypass the skip and perform a full poll
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent metrics on individual level but stale globally
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global webhook is stale
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
|
||||||
|
// Triggered immediately on start (flush microtasks)
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time by 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance by another 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user