Compare commits

...

28 Commits

Author SHA1 Message Date
gronod dcb77dd27f merge branch 'develop' into 'main' - Release v1.7.29
CI / Security audit (push) Successful in 2m47s
Create Release / release (push) Successful in 40s
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 3m32s
2026-05-27 23:51:12 +01:00
gronod f5315e5ceb chore: bump version to 1.7.29 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m39s
Docs Check / Markdown lint (push) Failing after 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m11s
CI / Swagger Validation & Coverage (push) Successful in 2m51s
CI / Security audit (push) Successful in 3m9s
Docs Check / Mermaid diagram parse check (push) Successful in 3m21s
CI / Tests & coverage (push) Failing after 3m44s
2026-05-27 23:46:40 +01:00
gronod 13f3d767c5 fix: resolve missing Radarr and Sonarr links on active downloads (fixes #59) 2026-05-27 23:46:35 +01:00
gronod 6c3ffb9b77 merge branch 'develop' into 'main' - Release v1.7.28
Create Release / release (push) Successful in 22s
CI / Swagger Validation & Coverage (push) Successful in 1m52s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m1s
CI / Tests & coverage (push) Successful in 3m38s
2026-05-27 23:26:11 +01:00
gronod a37874c553 chore: bump version to 1.7.28 and update CHANGELOG and docs
CI / Security audit (push) Successful in 1m27s
Build and Push Docker Image / build (push) Successful in 2m0s
Docs Check / Markdown lint (push) Failing after 2m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
Docs Check / Mermaid diagram parse check (push) Successful in 3m24s
CI / Tests & coverage (push) Successful in 3m34s
2026-05-27 23:25:57 +01:00
gronod 5933e09652 fix: resolve missing Sonarr link button on TV request cards (fixes #58) 2026-05-27 23:25:53 +01:00
gronod 7226404221 merge branch 'develop' into 'main' - Release v1.7.27
Create Release / release (push) Successful in 37s
CI / Swagger Validation & Coverage (push) Successful in 2m5s
CI / Security audit (push) Successful in 2m59s
Build and Push Docker Image / build (push) Successful in 1m39s
CI / Tests & coverage (push) Successful in 3m48s
2026-05-27 23:11:37 +01:00
gronod 1ee2a8044b chore: bump version to 1.7.27 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m53s
Docs Check / Mermaid diagram parse check (push) Successful in 2m46s
CI / Security audit (push) Successful in 3m14s
CI / Swagger Validation & Coverage (push) Successful in 3m32s
CI / Tests & coverage (push) Successful in 3m55s
2026-05-27 23:11:23 +01:00
gronod 86277e2059 fix: serve frontend static files and handle SPA routes (fixes #57) 2026-05-27 23:11:19 +01:00
gronod 0eaa54cf4a merge branch 'develop' into 'main' - Release v1.7.26
Create Release / release (push) Successful in 8s
CI / Security audit (push) Successful in 3m0s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
Build and Push Docker Image / build (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 3m37s
2026-05-27 22:50:31 +01:00
gronod 865cf1f57a chore: bump version to 1.7.26 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m12s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
Docs Check / Mermaid diagram parse check (push) Successful in 3m18s
CI / Tests & coverage (push) Successful in 3m28s
2026-05-27 22:50:29 +01:00
gronod ff5f50cc3a chore: remove reg.i3omb.com from build-image workflow
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
CI / Tests & coverage (push) Successful in 2m26s
Only publish container images to git.i3omb.com registry.
2026-05-27 21:46:34 +01:00
gronod fd0dc7528d merge branch 'develop' into 'main' - Release v1.7.25
Create Release / release (push) Successful in 23s
CI / Security audit (push) Successful in 1m49s
Build and Push Docker Image / build (push) Successful in 1m50s
CI / Swagger Validation & Coverage (push) Successful in 2m18s
CI / Tests & coverage (push) Successful in 2m30s
2026-05-27 21:43:45 +01:00
gronod 33b122d22b fix(ombi): resolve TV request status, user, and date display (Issue #53)
Build and Push Docker Image / build (push) Successful in 1m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m33s
CI / Security audit (push) Successful in 1m56s
CI / Swagger Validation & Coverage (push) Successful in 2m35s
CI / Tests & coverage (push) Successful in 2m51s
Ombi's TV API nests all request data (requestedUser, approved, available,
denied, requested, requestedDate) inside childRequests[] sub-objects.
The application previously only inspected top-level properties, causing
TV shows to consistently display 'unknown' status, 'unknown' user, and
no request date.

Changes:
- OmbiRetriever._hydrateRequest(): hydrate requestedUser on each
  childRequests entry and promote requestedDate to top level
- getRequestStatus() (server + client): aggregate status flags from
  childRequests[] when top-level properties are absent
- Client date display: fallback to childRequests[0].requestedDate
- Add 18 unit tests covering childRequests hydration, status
  aggregation, and date promotion

Closes #53
2026-05-27 21:13:17 +01:00
gronod c4e584cc3b merge branch 'develop' into 'main' - Release v1.7.24
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 57s
CI / Security audit (push) Successful in 3m7s
CI / Swagger Validation & Coverage (push) Successful in 1m49s
CI / Tests & coverage (push) Successful in 3m43s
2026-05-27 19:35:19 +01:00
gronod 35ff21a810 chore: bump version to 1.7.24 and update CHANGELOG and workflows
Build and Push Docker Image / build (push) Successful in 1m48s
Docs Check / Markdown lint (push) Successful in 1m36s
CI / Swagger Validation & Coverage (push) Successful in 2m9s
CI / Security audit (push) Successful in 2m10s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m23s
Docs Check / Mermaid diagram parse check (push) Successful in 3m2s
CI / Tests & coverage (push) Successful in 3m34s
2026-05-27 19:35:06 +01:00
gronod 610632c4f0 merge branch 'develop' into 'main' - Release v1.7.23
Build and Push Docker Image / build (push) Successful in 1m4s
Create Release / release (push) Successful in 53s
CI / Security audit (push) Successful in 1m42s
CI / Swagger Validation & Coverage (push) Successful in 1m42s
CI / Tests & coverage (push) Successful in 2m21s
2026-05-27 19:16:23 +01:00
gronod 5b3034e290 chore: bump version to 1.7.23 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Successful in 46s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m22s
CI / Security audit (push) Successful in 2m48s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
Docs Check / Mermaid diagram parse check (push) Successful in 3m9s
CI / Tests & coverage (push) Successful in 3m36s
2026-05-27 19:16:12 +01:00
gronod 1535a5725a merge branch 'develop' into 'main' - Release v1.7.22
Create Release / release (push) Successful in 29s
Build and Push Docker Image / build (push) Successful in 2m33s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
CI / Security audit (push) Successful in 2m26s
CI / Tests & coverage (push) Successful in 2m55s
2026-05-27 17:44:51 +01:00
gronod 95bd703b26 chore: bump version to 1.7.22 and update CHANGELOG, tests and docs
Docs Check / Markdown lint (push) Successful in 1m14s
Build and Push Docker Image / build (push) Successful in 1m52s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m25s
Docs Check / Mermaid diagram parse check (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
CI / Tests & coverage (push) Successful in 3m45s
2026-05-27 17:42:41 +01:00
gronod 8fb00843ef merge branch 'develop' into 'main' - Release v1.7.21
CI / Security audit (push) Successful in 2m50s
CI / Swagger Validation & Coverage (push) Successful in 3m23s
CI / Tests & coverage (push) Successful in 3m30s
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 11s
2026-05-26 15:20:40 +01:00
gronod d2ac7731ca chore: bump version to 1.7.21 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m21s
CI / Security audit (push) Successful in 1m26s
Docs Check / Markdown lint (push) Successful in 1m27s
CI / Swagger Validation & Coverage (push) Successful in 2m25s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m30s
Docs Check / Mermaid diagram parse check (push) Successful in 3m17s
CI / Tests & coverage (push) Successful in 3m31s
2026-05-26 15:20:12 +01:00
gronod 6f6aa5b967 merge branch 'develop' into 'main' - Release v1.7.20
Build and Push Docker Image / build (push) Successful in 1m13s
Create Release / release (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m36s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
CI / Tests & coverage (push) Successful in 3m15s
2026-05-26 13:43:33 +01:00
gronod 5390bbf615 chore: bump version to 1.7.20 and resolve Ombi user hydration issue
Build and Push Docker Image / build (push) Successful in 2m6s
Docs Check / Markdown lint (push) Successful in 1m58s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m4s
Docs Check / Mermaid diagram parse check (push) Successful in 1m58s
CI / Security audit (push) Successful in 1m48s
CI / Tests & coverage (push) Successful in 1m59s
CI / Swagger Validation & Coverage (push) Successful in 1m47s
2026-05-26 11:30:49 +01:00
gronod fb68bddedb merge branch 'develop' into 'main' - Update Release v1.7.19 to ignore scratch directory
Build and Push Docker Image / build (push) Successful in 1m8s
Create Release / release (push) Successful in 36s
CI / Tests & coverage (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 1m50s
2026-05-25 08:33:16 +01:00
gronod 81d0aa82f2 chore: add scratch/ to gitignore and untrack existing scratch files
Build and Push Docker Image / build (push) Successful in 1m17s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m26s
CI / Swagger Validation & Coverage (push) Successful in 3m1s
CI / Security audit (push) Successful in 3m15s
CI / Tests & coverage (push) Successful in 3m42s
2026-05-25 08:32:33 +01:00
gronod 7d7304637c merge branch 'develop' into 'main' - Release v1.7.19
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 2m19s
CI / Security audit (push) Successful in 2m14s
CI / Tests & coverage (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m24s
2026-05-25 08:28:28 +01:00
gronod d87ad9f1c7 fix: mobile request card overflow (#49) and admin arrLink active badges (#50), bump version to 1.7.19
Build and Push Docker Image / build (push) Successful in 1m23s
Docs Check / Markdown lint (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 3m19s
CI / Security audit (push) Successful in 3m21s
CI / Swagger Validation & Coverage (push) Successful in 3m40s
CI / Tests & coverage (push) Successful in 4m57s
2026-05-25 08:28:16 +01:00
48 changed files with 1685 additions and 662 deletions
+10
View File
@@ -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
+8 -10
View File
@@ -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
+6 -2
View File
@@ -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:
+2 -1
View File
@@ -11,4 +11,5 @@ data/
*.db-wal *.db-wal
*.db-shm *.db-shm
.agents/ .agents/
.windsurf/ .windsurf/
scratch/
+102
View File
@@ -4,6 +4,108 @@ 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.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
### Fixed
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
---
## [1.7.18] - 2026-05-24 ## [1.7.18] - 2026-05-24
### Fixed ### Fixed
+99 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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';
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.18", "version": "1.7.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.18", "version": "1.7.29",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.18", "version": "1.7.29",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
+21 -19
View File
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -1888,12 +1888,17 @@ body {
/* ===== Mobile ===== */ /* ===== Mobile ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.main-tabs {
padding: 0 8px;
}
.requests-container { .requests-container {
padding: 12px; padding: 8px;
} }
.request-card { .request-card {
gap: 8px; gap: 8px;
padding: 10px;
} }
.request-meta { .request-meta {
@@ -2258,6 +2263,7 @@ body {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
transition: box-shadow 0.2s ease, border-color 0.2s ease; transition: box-shadow 0.2s ease, border-color 0.2s ease;
min-width: 0;
} }
.request-card:hover { .request-card:hover {
-21
View File
@@ -1,21 +0,0 @@
Problem:
The "Blocklist & Search" button on download cards fails with a "400 Bad Request (Missing required fields)" when clicked on any television release in Sonarr that represents a full season package or a multi-episode release.
Root Cause:
1. In `server/services/DownloadMatcher.js`, when a download is matched with a Sonarr queue record, `arrContentId` is populated with `sonarrMatch.episodeId || null`.
2. However, for multi-episode packs or full season grabs in Sonarr v3, the `episodeId` field is missing from the queue record payload (since the release is associated with multiple episodes). Instead, Sonarr provides an `episodeIds` array. As a result, `arrContentId` is normalized to `null`.
3. When the user clicks the "Blocklist & Search" button in the UI, the frontend calls the `POST /api/dashboard/blocklist-search` endpoint. The request body includes `arrContentId: null`.
4. The backend route validator in `server/routes/dashboard.js` strictly requires all fields including `arrContentId` to be truthy:
```javascript
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
return res.status(400).json({ error: 'Missing required fields' });
}
```
Because `arrContentId` is `null`, this check fails and returns `400 Missing required fields`, completely blocking the blocklist operation (even though queue removal itself does not require an episode ID).
5. Furthermore, the search trigger logic in `dashboard.js` only handles single episode searches via `{ name: 'EpisodeSearch', episodeIds: [arrContentId] }` and has no logic to handle `episodeIds` arrays or fallback searches (such as `SeriesSearch` or `SeasonSearch`).
Proposed Fix:
1. **Relax Backend Validation**: Allow `arrContentId` to be optional or null for `sonarr` queue records to ensure the deletion and blocklist steps can still execute.
2. **Robust Search Triggers**:
- If `episodeId` is missing but `episodeIds` array is available on the matched record, pass the array of IDs to the frontend/backend.
- Modify the `dashboard.js` re-search block to support `EpisodeSearch` with multiple IDs, or fall back to triggering a `SeriesSearch` command using the `seriesId` if no specific episode IDs are resolved.
-16
View File
@@ -1,16 +0,0 @@
Title:
FEATURE: Client-side console log capturing and streaming API endpoint with dual-authentication
Problem / Requirement:
To aid in frontend troubleshooting, developers need a way to capture and gather client-side console logs (`console.log`, `console.warn`, `console.error`) and make them accessible over a real-time log stream endpoint. This helps debug frontend issues (such as SSE failures, CSP violations, and state synchronization issues) in environments without direct access to browser devtools.
Success Criteria:
1. Client-Side Interceptor: Intercept standard browser console methods at SPA startup and place captured logs into an in-memory queue.
2. Batched Log Transmission (Selected Option A): Periodic HTTP POST batch queries to `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize browser thread and network overhead.
3. Server storage and SSE log streaming:
- Save incoming logs into a separate rolling 1000-line buffer `clientLogBuffer`.
- Expose `GET /api/debug/client-logs/stream` to stream client-side logs in real-time via SSE.
4. Security & Configuration:
- Enableable only when the environment variable `ENABLE_LOG_STREAM=true` is set.
- Enforce exact same dual-auth rules (Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass) on both client logs endpoints.
5. API Documentation: Documented in `server/openapi.yaml`.
-12
View File
@@ -1,12 +0,0 @@
Amended the plan to add client-side console log capturing and streaming options:
### Proposed Client Logging Design:
- **Client-Side Capture (Frontend Interception)**: Hook into standard browser console methods (`console.log`, `console.warn`, `console.error`) at client-side startup.
- **Client-to-Server Transmission**:
- **Option A (Recommended)**: Store captured logs in a local memory queue, and periodically perform a batched `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize network overhead.
- **Option B (WebSocket Channel)**: Stream logs instantly via persistent WebSockets, which adds structural and connection management complexity.
- **Server Storage & SSE Streaming**:
- Store incoming client logs in a separate rolling 1000-line buffer `clientLogBuffer`.
- Expose `GET /api/debug/client-logs/stream` (under the exact same dual-auth/webhook-secret constraints) to stream client-side logs in real-time via SSE to debugging tools.
The `implementation_plan.md` artifact has been successfully updated with these options.
-32
View File
@@ -1,32 +0,0 @@
const axios = require('axios');
const fs = require('fs');
const secret = '63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a';
const serverLogsUrl = 'https://sofarr.i3omb.com/api/debug/server-logs?testClose=true';
const clientLogsUrl = 'https://sofarr.i3omb.com/api/debug/client-logs?testClose=true';
async function fetchLogs(url, filename) {
console.log(`Fetching logs from ${url}...`);
try {
const response = await axios.get(url, {
headers: {
'x-webhook-secret': secret
}
});
fs.writeFileSync(filename, response.data);
console.log(`Logs saved to ${filename} (${response.data.length} bytes).`);
} catch (err) {
console.error(`Failed to fetch from ${url}:`, err.message);
if (err.response) {
console.error(`Status: ${err.response.status}`);
console.error(`Body:`, JSON.stringify(err.response.data));
}
}
}
async function run() {
await fetchLogs(serverLogsUrl, 'scratch/remote_server.log');
await fetchLogs(clientLogsUrl, 'scratch/remote_client.log');
}
run();
-26
View File
@@ -1,26 +0,0 @@
## Regression: Fix in v1.7.16 was insufficient — issue persists in production
### Updated Root Cause Analysis
Post-release investigation of live server debug logs on `sofarr.i3omb.com` confirms the blocklist feature is **still failing** after v1.7.16. The server logs still show:
```
[Blocklist] Download not found: { arrQueueId: 439913856, arrType: 'radarr' }
```
The v1.7.16 fix cast both sides of the comparison to `String`, which was the correct approach — but it was applied to the **wrong data source**.
The permission check at line 693 of `dashboard.js` calls:
```js
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
```
`downloadClientRegistry.getAllDownloads()` fetches **raw download client data** directly from qBittorrent, SABnzbd, etc. — these are unmatched objects with no Sonarr/Radarr queue metadata. The `arrQueueId` field is only populated during `DownloadMatcher.js` processing (which runs during the SSE/dashboard build from the *arr cache). Because qBittorrent's `normalizeDownload()` never sets `arrQueueId`, the lookup **always returns `undefined`** for any qBittorrent torrent, regardless of type casting.
### Correct Fix
The permission check should validate against the **Sonarr/Radarr queue cache records** directly (where `id` is the queue record ID), rather than against raw download client data. The fix will replace the `downloadClientRegistry.getAllDownloads()` lookup with a direct cache lookup of `poll:sonarr-queue` / `poll:radarr-queue` records, matching by `String(record.id) === String(arrQueueId)`.
This will be released in v1.7.17.
-46
View File
@@ -1,46 +0,0 @@
## Summary
The "Blocklist and search" feature is broken for all users. Clicking the blocklist button on a download (e.g. the film "Project Hail Mary", `arrQueueId: 905000340`, `arrType: radarr`) consistently returns a `403 Download not found or permission denied` error.
## Root Cause
The server-side lookup in `server/routes/dashboard.js` uses strict equality (`===`) to find the matching download:
```js
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
```
- `d.arrQueueId` is populated from the Radarr/Sonarr queue API response as a **number** (e.g. `905000340`).
- `arrQueueId` from `req.body` originates from the client SPA via a DOM `dataset` attribute, which is always a **string** (e.g. `"905000340"`).
- Due to the type mismatch, `905000340 === "905000340"` evaluates to `false`, so the lookup always fails and returns `403`.
## Evidence
Server log (live environment, `2026-05-24`):
```
[Blocklist] Download not found: { arrQueueId: 905000340, arrType: 'radarr' }
```
Client log confirms user clicked blocklist at `21:01:19`, `21:01:32`, and `21:02:35`.
## Steps to Reproduce
1. Open the dashboard on a Radarr or Sonarr download with a pending queue entry.
2. Click the "Blocklist and search" button.
3. The action silently fails; the download is not removed and no re-search is triggered.
4. Server logs show `[Blocklist] Download not found`.
## Proposed Fix
Cast both sides of the comparison to `String` before comparing:
```js
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
```
This fix will be released in version `1.7.16`.
## Severity
**High** — The blocklist-and-search feature is completely non-functional for all users. There is no workaround within the UI.
-19
View File
@@ -1,19 +0,0 @@
Title:
FEATURE: Log streaming debug endpoint with dual-authentication and togglable runtime configuration
Problem / Requirement:
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
Success Criteria:
1. **Lightweight Log Streaming**: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
2. **Dual-Authentication**:
- Accepts existing session cookie (`emby_user`) with administrative credentials.
- Accepts standard HTTP Basic Authentication (`Authorization: Basic <base64>`) using Emby administrator username/password credentials.
3. **Runtime Configuration Toggle**: Enableable using a runtime environment variable `ENABLE_LOG_STREAM=true` (defaulting to `false`/disabled). When disabled, returns a `403 Forbidden` response.
4. **API Spec Documentation**: Documented in `server/openapi.yaml` under the `/api/debug/logs` endpoint, including the query format and response schemas.
Proposed Implementation:
1. **Log Interceptor**: Implement a global stdout/stderr hook in `server/index.js` or in a new `server/utils/logCapture.js` to collect a rolling buffer of 1000 log lines and expose a Node `EventEmitter` to push new logs to active subscribers.
2. **Authentication Middleware**: Create `server/middleware/logStreamAuth.js` which verifies active sessions or fallback Basic Auth headers by calling Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to verify the user is a valid administrator.
3. **Route Definition**: Define `server/routes/debug.js` to register `GET /api/debug/logs` backing the SSE stream, enforce the `ENABLE_LOG_STREAM === 'true'` check, and execute `logStreamAuth` checks.
4. **OpenAPI Spec Integration**: Define `/api/debug/logs` schemas, parameters, security schemes, and basic auth descriptions inside `server/openapi.yaml`.
-21
View File
@@ -1,21 +0,0 @@
### Bug Description
Ombi webhooks are currently failing to authenticate. In `server/routes/webhook.js`, all `/api/webhook/*` endpoints (sonarr, radarr, and ombi) require the custom `X-Sofarr-Webhook-Secret` HTTP header to be present and match the configured `SOFARR_WEBHOOK_SECRET`.
However, Ombi's built-in Webhook notification agent does not support adding custom HTTP headers to its outgoing webhook notification requests. This makes it impossible for Ombi to successfully authenticate using the current header-only validation mechanism.
### Root Cause
In `server/routes/webhook.js`, `validateWebhookSecret(req)` only inspects `req.get('X-Sofarr-Webhook-Secret')`:
```javascript
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
...
}
```
Since Ombi sends standard JSON payloads to a configured URL without custom headers, it cannot supply this header, resulting in a `401 Unauthorized` response.
### Proposed Remediation
1. **Fallback Authentication Method**: Update `validateWebhookSecret(req)` in `server/routes/webhook.js` to look for the secret in either the `X-Sofarr-Webhook-Secret` header OR as a `secret` query parameter (`req.query.secret`).
2. **Registration Update**: Update the `/webhook/enable` route in `server/routes/ombi.js` to automatically append `?secret=${webhookSecret}` to the registered `webhookUrl` sent to Ombi.
3. **OpenAPI Spec & JSDoc Updates**: Document the query-parameter fallback authentication option in `server/openapi.yaml` and the `@openapi` JSDoc comments in `server/routes/webhook.js`.
4. **Integration Testing**: Add new integration tests in `tests/integration/webhook.test.js` to assert that authentication via query parameters succeeds, and that invalid query parameters are rejected.
-6
View File
@@ -1,6 +0,0 @@
Amended the plan to include a high-priority bypass using the `X-Webhook-Secret` request header:
1. **Webhook Secret Bypass**: If the request contains the `X-Webhook-Secret` header, we verify if it matches the configured `SOFARR_WEBHOOK_SECRET` environment variable.
2. **Access Granted**: If matching, the request is immediately authorized, completely bypassing session and Emby Basic Auth checks. This is ideal for curl scripts, server-to-server monitoring, or external debugging logs captures.
I have updated the `implementation_plan.md` artifact to reflect this amendment.
-15
View File
@@ -1,15 +0,0 @@
I have investigated the blocklist & search failure reported in this issue and created a technical remediation plan:
### Root Cause
For television grabs representing a full-season pack or multi-episode package in Sonarr, the `episodeId` property is absent (instead, it has an `episodeIds` array). This maps to a `null` value for `arrContentId` on the client download card. The `/api/dashboard/blocklist-search` route strictly requires all fields including `arrContentId` to be truthy, returning `400 Bad Request: Missing required fields` and completely blocking the queue blocklist/removal action.
### Remediation Plan
1. **Enrich Backend Match Data**: Expose `arrContentIds` (`sonarrMatch.episodeIds`) and `arrSeriesId` (`sonarrMatch.seriesId`) from `DownloadMatcher.js` to the normalized download card object.
2. **Relax API Route Validation**: Remove `arrContentId` from the mandatory request parameters check in `server/routes/dashboard.js`.
3. **Enhance Search Commands**:
- If a single `arrContentId` is provided, trigger `EpisodeSearch` for that single ID.
- If an `arrContentIds` array is provided, trigger `EpisodeSearch` with that list of IDs.
- If no specific episode IDs can be resolved but `arrSeriesId` is provided, fall back to triggering a series-wide `SeriesSearch`.
4. **Update Frontend & Documentation**: Update the client payload, update the OpenAPI spec, and add integration tests covering single/multi/fallback searches.
Upon approval, I will execute this plan, merge to `main`, close this ticket referencing the resolving commit, and cut a new point release (v1.7.11).
-14
View File
@@ -1,14 +0,0 @@
Title:
FEATURE: Togglable server-side (Docker) log streaming debug endpoint with dual-authentication
Problem / Requirement:
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
Success Criteria:
1. Lightweight Log Streaming: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
2. Dual-Authentication with Webhook Secret Bypass:
- Accepts existing session cookie (emby_user) with administrative credentials.
- Accepts standard HTTP Basic Authentication (Authorization: Basic <base64>) using Emby administrator username/password credentials.
- Accepts X-Webhook-Secret header matching the SOFARR_WEBHOOK_SECRET environment variable for programmatic bypass.
3. Runtime Configuration Toggle: Enableable using a runtime environment variable ENABLE_LOG_STREAM=true (defaulting to false/disabled). When disabled, returns a 403 Forbidden response.
4. API Spec Documentation: Documented in server/openapi.yaml under the /api/debug/logs endpoint, including the query format and response schemas.
-9
View File
@@ -1,9 +0,0 @@
Release v1.7.16
Remediate the blocklist-search queue ID type mismatch. The "Blocklist and
search" action was returning 403 for all users because the arrQueueId
comparison used strict equality between a string (from the SPA DOM dataset)
and a number (from the Radarr/Sonarr API). Both values are now cast to
String before comparison.
See CHANGELOG.md for full details.
-1
View File
@@ -1 +0,0 @@
63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a
+40 -1
View File
@@ -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.16" * example: "1.7.29"
* 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) => {
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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.16"
*/
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.
+22 -1
View File
@@ -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.18 version: 1.7.29
contact: contact:
name: sofarr name: sofarr
license: license:
@@ -176,6 +176,27 @@ components:
nullable: true nullable: true
description: Tooltip text for Ombi icon ("Request" or "Search") description: Tooltip text for Ombi icon ("Request" or "Search")
example: "Request" example: "Request"
arrLink:
type: string
nullable: true
format: uri
description: Sonarr/Radarr show/movie web UI link (admin-only)
example: "http://sonarr:8989/series/show-slug"
downloadPath:
type: string
nullable: true
description: Save path in download client (admin-only)
example: "/downloads/series/show-slug"
targetPath:
type: string
nullable: true
description: Target path in library (admin-only)
example: "/tv/show-slug"
arrInstanceKey:
type: string
nullable: true
description: Sonarr/Radarr instance API key (admin-only)
example: "api-key-here"
DashboardPayload: DashboardPayload:
type: object type: object
+47 -7
View File
@@ -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');
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
function buildMetadataMaps(snapshot) { function buildMetadataMaps(snapshot) {
const seriesMap = new Map(); const seriesMap = new Map();
for (const r of snapshot.sonarrQueue.data.records) { for (const r of snapshot.sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
seriesMap.set(r.seriesId, r.series);
}
} }
for (const r of snapshot.sonarrHistory.data.records) { for (const r of snapshot.sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series); if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
const existing = seriesMap.get(r.seriesId);
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
seriesMap.set(r.seriesId, r.series);
}
}
} }
const moviesMap = new Map(); const moviesMap = new Map();
for (const r of snapshot.radarrQueue.data.records) { for (const r of snapshot.radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
moviesMap.set(r.movieId, r.movie);
}
} }
for (const r of snapshot.radarrHistory.data.records) { for (const r of snapshot.radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie); if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
const existing = moviesMap.get(r.movieId);
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
moviesMap.set(r.movieId, r.movie);
}
}
} }
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label])); const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
@@ -192,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,
@@ -487,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(),
@@ -499,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
View File
@@ -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) {
+6 -6
View File
@@ -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`, {
+6 -6
View File
@@ -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`, {
+62 -5
View File
@@ -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}`);
}); });
+12
View File
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null; dlObj.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getSonarrLink(series); dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
} }
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null; dlObj.targetPath = movie.path || null;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie); dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
} }
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = series.path || null; download.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getSonarrLink(series); download.arrLink = DownloadAssembler.getSonarrLink(series);
download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrInstanceKey = sonarrMatch._instanceKey || null;
} }
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null; download.targetPath = movie.path || null;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getRadarrLink(movie); download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrInstanceKey = radarrMatch._instanceKey || null;
} }
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
}; };
+178
View File
@@ -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');
});
});
+94
View File
@@ -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);
});
});
+42
View File
@@ -349,6 +349,7 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.arrQueueId).toBe(1002); expect(dl.arrQueueId).toBe(1002);
expect(dl.arrType).toBe('sonarr'); expect(dl.arrType).toBe('sonarr');
expect(dl.arrInstanceUrl).toBe(SONARR_BASE); expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
expect(dl.downloadPath).toBeDefined(); expect(dl.downloadPath).toBeDefined();
}); });
@@ -562,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');
});
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+71 -1
View File
@@ -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);
});
}); });
+149
View File
@@ -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');
});
});
+43
View File
@@ -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([]);
});
});
}); });
+66
View File
@@ -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');
});
});
}); });
+34
View File
@@ -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
View File
@@ -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', () => {