Compare commits

..

35 Commits

Author SHA1 Message Date
gronod 3e06bdf8cd Update CHANGELOG.md with 1.5.2 and 1.5.3; update README.md version reference
Build and Push Docker Image / build (push) Successful in 28s
Create Release / release (push) Successful in 6s
Docs Check / Markdown lint (push) Successful in 56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 46s
Docs Check / Mermaid diagram parse check (push) Successful in 1m31s
CI / Security audit (push) Successful in 1m50s
CI / Tests & coverage (push) Successful in 1m55s
2026-05-19 23:11:47 +01:00
gronod ca1c136d4f Merge branch 'develop'
Build and Push Docker Image / build (push) Successful in 43s
CI / Security audit (push) Successful in 1m23s
Create Release / release (push) Successful in 11s
CI / Tests & coverage (push) Successful in 1m42s
2026-05-19 23:09:23 +01:00
gronod a04f2c9b25 Bump version to 1.5.3 2026-05-19 23:09:23 +01:00
gronod 743b169989 Fix webhooks panel: hide on app load to sync with status panel
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 38s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-19 23:05:20 +01:00
gronod 794cb7268e Fix status panel: remove innerHTML wipe that destroys status-content div
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m27s
2026-05-19 23:01:14 +01:00
gronod d310d101ed Fix undefined --background CSS variable causing blank status panel
Build and Push Docker Image / build (push) Successful in 44s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m34s
2026-05-19 22:59:16 +01:00
gronod 96f24eb3b7 Fix status card regression: revert webhooks-section to sibling structure
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m14s
2026-05-19 22:57:21 +01:00
gronod abcb9bfded debug: Add DOM structure verification to trace missing contentDiv
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Successful in 1m34s
2026-05-19 22:35:05 +01:00
gronod e5920b207f debug: Add more detailed logging to renderStatusPanel
Build and Push Docker Image / build (push) Successful in 26s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m38s
2026-05-19 22:33:09 +01:00
gronod d3483f3be7 debug(ui): Add visible styling and debug logging for status panel
Build and Push Docker Image / build (push) Successful in 25s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m26s
Added debug logging to trace status panel rendering:
- Log when refresh starts
- Log when data is received
- Log errors with details

Also added visible dashed border and background to #status-content
to make it obvious when the div is present but empty.
2026-05-19 22:30:54 +01:00
gronod 252cc50aa4 fix(ui): Add loading state and min-height for status-content
Build and Push Docker Image / build (push) Successful in 39s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Has been cancelled
Added loading indicator text and min-height CSS for #status-content
to prevent the empty card appearance when status panel first opens.
2026-05-19 22:29:03 +01:00
gronod 57908e2b9e fix(ui): Add status-content container to preserve webhooks panel
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 50s
CI / Security audit (push) Successful in 1m1s
CI / Tests & coverage (push) Successful in 1m19s
The webhooks panel was being destroyed when renderStatusPanel set
panel.innerHTML. Added a dedicated #status-content div for status
data, keeping webhooks section intact when status refreshes.
2026-05-19 22:27:11 +01:00
gronod e2757768c7 fix(ui): Integrate webhooks panel into status panel
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m35s
The webhooks panel was appearing separately from the status panel.
Now it's properly nested inside the status-panel div:

- Moved webhooks-section inside status-panel in HTML
- Updated CSS so nested webhooks looks like a subsection (no double borders)
- Simplified JS toggle logic - webhooks shows/hides automatically with status panel
- Admin users see webhooks inside status panel, collapsed by default
2026-05-19 22:24:15 +01:00
gronod 2469c3e3f4 fix(pagination): Increase Sonarr/Radarr page sizes to fetch all items
Build and Push Docker Image / build (push) Successful in 20s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Successful in 1m27s
Sonarr Activity tab has 12 pages but we only fetched ~2 items.
Added pageSize=1000 to queue API and changed history default from 10 to 100.
This ensures all downloads are available for matching to SAB/qBittorrent.
2026-05-19 22:20:09 +01:00
gronod 6c8c333c6a debug: Add Sonarr queue titles to no-match output
Build and Push Docker Image / build (push) Successful in 49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m0s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Successful in 1m29s
2026-05-19 22:16:26 +01:00
gronod 5dfe0b1216 fix(matching): Match SAB to Sonarr by downloadId first
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m27s
Sonarr tracks the exact SAB download ID (nzo_id). Now tries to match
by downloadId first, then falls back to title matching. Also adds
debug to show if matches are via downloadId vs title, and logs
downloadIds in history to verify the link exists.
2026-05-19 22:13:43 +01:00
gronod 77beef787f debug(matching): Show queue vs history source and history titles
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m30s
When a match is found, logs whether it came from queue or history.
When no match, shows history counts and sample titles to verify
history is being checked properly.
2026-05-19 22:10:34 +01:00
gronod 235a866ec8 fix(matching): Check Sonarr/Radarr history for SAB matches
Build and Push Docker Image / build (push) Successful in 44s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m34s
SAB items often persist after Sonarr has processed them.
Previously only checked the active queue, now also checks
history records so completed downloads still appear.
2026-05-19 22:06:38 +01:00
gronod f1d9de2a92 debug(sonarr): Log all available Sonarr queue fields
Build and Push Docker Image / build (push) Successful in 28s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Successful in 1m39s
Shows title, sourceTitle, series.title, episode.title for
each Sonarr queue item to understand the data structure.
2026-05-19 22:04:11 +01:00
gronod 9d0e31ec9a fix(matching): Normalize dots to spaces for SAB/Sonarr matching
Build and Push Docker Image / build (push) Successful in 13s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 46s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
SAB filenames use dots (dora.the.explorer.s02e08) but Sonarr titles
use spaces (Dora the Explorer - S02E08). Now tries matching with
both formats to improve match rate.

Also logs actual Sonarr titles when no match found for debugging.
2026-05-19 22:02:55 +01:00
gronod 42c3eebf18 debug(sse): Add detailed name matching logging
Build and Push Docker Image / build (push) Successful in 29s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m4s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m49s
Shows exactly which SAB items match/don't match to Sonarr/Radarr:
- ✓ Sonarr match: SAB name → Sonarr name
- ✓ Radarr match: SAB name → Radarr name
- ✗ No match: SAB name (with Sonarr queue count)

This will help diagnose why Sonarr Activity Queue shows matches but Sofarr doesn't.
2026-05-19 21:50:05 +01:00
gronod f295e1c90d debug(sse): Add SAB matching stats to trace filtering
Build and Push Docker Image / build (push) Successful in 36s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 49s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m27s
Shows how many SAB items were checked vs how many matched to Sonarr/Radarr.
This will help diagnose why only ~10 of 60 SAB items are appearing.
2026-05-19 21:47:12 +01:00
gronod c5e8281440 fix(sabnzbd): Handle labels as array or string
Build and Push Docker Image / build (push) Successful in 43s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m7s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m47s
SABnzbd API returns labels as an array in newer versions,
but the code assumed it was a comma-separated string.
Now handles both cases to prevent 'slot.labels.split is not a function' error.
2026-05-19 21:43:58 +01:00
gronod f22dd0d1f6 fix(downloads): Fix SABnzbd/qBittorrent collision and webhook metrics
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m41s
1. Fixed download client collision:
   - SABnzbd client with id 'i3omb' was being overwritten by qBittorrent
   - Now uses unique key ':' like the arr retrievers

2. Fixed webhook metrics showing 0:
   - instanceName from webhooks is generic ('Sonarr', 'Radarr')
   - Not the configured instance name ('i3omb')
   - Now updates metrics for ALL instances of that type
2026-05-19 21:40:53 +01:00
gronod 5159a83475 fix(retrievers): Use unique key to prevent Sonarr/Radarr collision
Build and Push Docker Image / build (push) Successful in 33s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m10s
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m52s
When Sonarr and Radarr had the same instance ID (e.g., 'i3omb'),
the Radarr retriever would overwrite the Sonarr retriever in the Map.
This caused webhook refreshes to show '0 instance(s)' for Sonarr.

Now uses ':' as the unique key so both can coexist.
2026-05-19 21:36:20 +01:00
gronod ccc3b6ffec fix(status): Check actual webhook config, show enabled even with 0 events
Build and Push Docker Image / build (push) Successful in 46s
Licence Check / Licence compatibility and copyright header verification (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
The status panel was showing webhooks as disabled (null) when no events
had been received yet. Now it checks Sonarr/Radarr API to see if the
Sofarr webhook notification is actually configured.

- Added checkWebhookConfigured() to verify webhook exists in Sonarr/Radarr
- Shows 'enabled: true' with 0 events when webhook is configured
- Only shows null when webhook is not configured at all
2026-05-19 21:35:26 +01:00
gronod 4ec7d734b8 debug(sse): Add detailed logging for download matching
Build and Push Docker Image / build (push) Successful in 34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m38s
Add debug logging to trace:
- When downloads payload is built
- Data sizes from cache (SAB, qBit, Sonarr, Radarr)
- Number of downloads found and their titles

This will help diagnose why Dora downloads aren't appearing.
2026-05-19 21:32:15 +01:00
gronod 2e85fae57a fix(webhooks): Load collapsed by default, add webhook metrics to status panel
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m53s
Build and Push Docker Image / build (push) Successful in 35s
- Fixed webhooks section to load collapsed (content hidden, toggle arrow reset)
- Added webhook metrics card to status panel for admin users:
  - Shows Sonarr/Radarr enabled/disabled status
  - Shows events received and polls skipped counts
- Updated /api/dashboard/status endpoint to include webhook metrics
- Metrics are aggregated from all Sonarr/Radarr instances
2026-05-19 21:24:28 +01:00
gronod aeacadbe68 refactor(webhooks): Integrate webhooks panel into status card
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 59s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m33s
- Moved webhooks-section to be inline with status-panel in HTML
- Updated toggleStatusPanel() to show/hide webhooks section for admin users
- Updated closeStatusPanel() to also hide webhooks section
- Removed webhooks visibility from showDashboard() - now tied to status panel
- Updated CSS to make webhooks section styling consistent with status panel:
  - Same border, border-radius, margin, box-shadow
  - Updated webhook-stats to use status-card styling (background, border)
- Webhooks metrics now display inline with status panel for admin users
2026-05-19 21:20:34 +01:00
gronod 3ef35a8c43 fix(webhooks): Send full notification object to test endpoint
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m42s
The /notifications/test endpoint requires the full notification object,
not just the ID. Changed testSonarrWebhook() and testRadarrWebhook() to
send the complete notification object (sonarrSofarr/radarrSofarr).

Fixes: 400 validation error when testing webhooks
2026-05-19 21:16:31 +01:00
gronod 0f3c02e52d fix(webhooks): Use numeric method value (1=POST) in notification payload
Build and Push Docker Image / build (push) Successful in 44s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m33s
The webhook notification payload was using string 'POST' for the method
field, but Sonarr/Radarr API expects numeric values:
- 1 = POST
- 2 = PUT

Also added onManualInteractionRequired: false to match the schema.

Fixes: Radarr/Sonarr rejecting webhook configuration with validation errors
2026-05-19 20:47:19 +01:00
gronod 9fd60bcfed fix(webhooks): Use SONARR_INSTANCES/RADARR_INSTANCES config for notification routes
Build and Push Docker Image / build (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m36s
The notification routes were using process.env.SONARR_URL directly,
which is undefined when using the newer SONARR_INSTANCES JSON format.

Changes:
- Added getFirstSonarrInstance() and getFirstRadarrInstance() helpers
- Updated /notifications, /notifications/test, and /notifications/sofarr-webhook
  routes to use instance config from getSonarrInstances()/getRadarrInstances()
- Returns 503 error if no instances are configured

Fixes: 'Invalid URL' errors when calling Sonarr/Radarr notification APIs
2026-05-19 20:42:59 +01:00
gronod af58e1bf2a debug(webhooks): Add console.error logging to Sonarr/Radarr notification routes
Build and Push Docker Image / build (push) Successful in 27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m3s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m34s
Added detailed error logging to help diagnose 500 errors when calling
Sonarr/Radarr notification APIs. Logs include:
- Error message
- Response status (if available)
- Response data (if available)

This will help identify if the issue is:
- Missing SONARR_URL/RADARR_URL or API keys
- Network connectivity issues
- Sonarr/Radarr API version incompatibility
2026-05-19 20:39:37 +01:00
gronod 2d04402284 fix(webhooks): Show webhooks panel only to admin users
Build and Push Docker Image / build (push) Successful in 39s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m4s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 20:36:33 +01:00
gronod 0310f10e5d fix(webhooks): Restore original vanilla JS app and add webhooks panel properly
Build and Push Docker Image / build (push) Successful in 1m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
CI / Security audit (push) Successful in 1m22s
CI / Tests & coverage (push) Successful in 1m43s
The React build replaced the full-featured vanilla JS app with a
simpler UI, causing the dashboard to disappear and lose theming.

This commit:
- Restores original vanilla JS app with auth, themes, tabs, history
- Adds Webhooks Configuration panel for admin users
- Adds webhook status, enable/test buttons, triggers, and stats
- Uses proper CSS variables for theme support

Fixes the dashboard disappearing issue and restores all original functionality.
2026-05-19 20:33:23 +01:00
15 changed files with 3755 additions and 114 deletions
+19
View File
@@ -6,6 +6,25 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
---
## [1.5.3] - 2026-05-19
### Fixed
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
---
## [1.5.2] - 2026-05-19
### Fixed
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
---
## [1.5.1] - 2026-05-19
### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
Version 1.4.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
Version 1.5.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
## What It Does
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.5.2",
"version": "1.5.3",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
+1501 -48
View File
File diff suppressed because one or more lines are too long
+184 -10
View File
@@ -1,14 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media Download Dashboard</title>
<script type="module" crossorigin src="app.js"></script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<meta name="theme-color" content="#1a1a2e">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="root"></div>
</body>
</head>
<body>
<!-- Splash Screen -->
<div id="splash-screen" class="splash-screen">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;">
<div class="login-box">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
<form id="login-form">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="rememberMe">
<span>Keep me logged in</span>
</label>
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
</div>
</div>
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<header class="app-header">
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
<div class="header-controls">
<div class="theme-switcher">
<button class="theme-btn active" data-theme="light">Light</button>
<button class="theme-btn" data-theme="dark">Dark</button>
<button class="theme-btn" data-theme="mono">Mono</button>
</div>
<div id="admin-controls" class="admin-controls" style="display: none;">
<label class="toggle-label">
<input type="checkbox" id="show-all-toggle">
<span>Show all users</span>
</label>
<button id="status-btn" class="status-btn">Status</button>
</div>
<div class="user-info">
<span class="user-label">Current User:</span>
<span class="user-name" id="currentUser">-</span>
<button id="logout-btn" class="logout-btn">Logout</button>
</div>
</div>
</header>
<div id="status-panel" class="status-panel" style="display: none;">
<!-- Status content gets rendered here -->
<div id="status-content"><p class="status-loading">Loading status...</p></div>
</div>
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
<div class="webhooks-section" id="webhooks-section" style="display: none;">
<div class="webhooks-header" id="webhooks-header">
<h2>⚡ Webhooks Configuration</h2>
<span class="webhooks-toggle" id="webhooks-toggle"></span>
</div>
<div class="webhooks-content" id="webhooks-content" style="display: none;">
<div id="webhook-loading" class="webhook-loading" style="display: none;">Loading webhook status...</div>
<!-- Sonarr Webhook -->
<div class="webhook-instance">
<h3>Sonarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
<button id="enable-sonarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
<button id="test-sonarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
</div>
<div class="webhook-triggers" id="sonarr-triggers" style="display: none;">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats" id="sonarr-stats" style="display: none;">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="sonarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
</div>
</div>
</div>
<!-- Radarr Webhook -->
<div class="webhook-instance">
<h3>Radarr</h3>
<div class="webhook-status">
<span class="status-indicator" id="radarr-status">○ Disabled</span>
<button id="enable-radarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
<button id="test-radarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
</div>
<div class="webhook-triggers" id="radarr-triggers" style="display: none;">
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab"></span></div>
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload"></span></div>
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport"></span></div>
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade"></span></div>
</div>
<div class="webhook-stats" id="radarr-stats" style="display: none;">
<div class="webhook-stats-title">Statistics</div>
<div class="webhook-stats-grid">
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="radarr-events">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
</div>
</div>
</div>
</div>
</div>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
<div class="main-tabs">
<div class="tab-bar">
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
<button class="tab-btn" data-tab="history">Recently Completed</button>
</div>
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
</div>
<div class="tab-panel" id="tab-history" style="display: none;">
<div class="history-container" id="history-container">
<div class="history-header">
<div class="history-controls">
<label class="history-days-label" for="history-days">Last</label>
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span>
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">&#8635;</button>
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
<input type="checkbox" id="ignore-available-toggle">
<span>Hide upgrade failures</span>
</label>
</div>
</div>
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
<div id="history-error" class="history-error" style="display: none;"></div>
<div id="no-history" class="no-history" style="display: none;">
<p>No completed downloads found in this period.</p>
</div>
<div id="history-list" class="history-list"></div>
</div>
</div>
</div>
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
</footer>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+1753 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -38,9 +38,10 @@ class PollingRadarrRetriever extends ArrRetriever {
*/
async getQueue() {
try {
// Fetch with large page size to get all items (Radarr has pagination)
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeMovie: true }
params: { includeMovie: true, pageSize: 1000 }
});
return response.data;
} catch (error) {
@@ -61,7 +62,7 @@ class PollingRadarrRetriever extends ArrRetriever {
*/
async getHistory(options = {}) {
const {
pageSize = 10,
pageSize = 100,
sortKey,
sortDir,
includeMovie = true,
+3 -2
View File
@@ -38,9 +38,10 @@ class PollingSonarrRetriever extends ArrRetriever {
*/
async getQueue() {
try {
// Fetch with large page size to get all items (Sonarr has pagination)
const response = await axios.get(`${this.url}/api/v3/queue`, {
headers: { 'X-Api-Key': this.apiKey },
params: { includeSeries: true, includeEpisode: true }
params: { includeSeries: true, includeEpisode: true, pageSize: 1000 }
});
return response.data;
} catch (error) {
@@ -62,7 +63,7 @@ class PollingSonarrRetriever extends ArrRetriever {
*/
async getHistory(options = {}) {
const {
pageSize = 10,
pageSize = 100,
sortKey,
sortDir,
includeSeries = true,
+1 -1
View File
@@ -167,7 +167,7 @@ class SABnzbdClient extends DownloadClient {
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
eta: this.calculateEta(slot.timeleft || slot.eta),
category: slot.cat || undefined,
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
savePath: slot.final_name || undefined,
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
arrQueueId: arrInfo.queueId,
+175 -10
View File
@@ -772,7 +772,7 @@ router.get('/user-summary', requireAuth, async (req, res) => {
});
// Admin-only status page with cache stats
router.get('/status', requireAuth, (req, res) => {
router.get('/status', requireAuth, async (req, res) => {
try {
const user = req.user;
if (!user.isAdmin) {
@@ -782,6 +782,69 @@ router.get('/status', requireAuth, (req, res) => {
const cacheStats = cache.getStats();
const uptime = process.uptime();
// Get webhook metrics
const { getGlobalWebhookMetrics } = require('../utils/cache');
const webhookMetrics = getGlobalWebhookMetrics();
// Check if Sofarr webhook is configured in Sonarr/Radarr
async function checkWebhookConfigured(instance, type) {
try {
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey },
timeout: 5000
});
const notifications = response.data || [];
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
} catch (err) {
console.log(`[Status] Failed to check ${type} webhook config: ${err.message}`);
return false;
}
}
// Check webhook configuration for each service
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
const sonarrWebhookConfigured = sonarrInstances.length > 0
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
: false;
const radarrWebhookConfigured = radarrInstances.length > 0
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
: false;
// Find Sonarr and Radarr metrics from instances
const sonarrMetrics = {};
const radarrMetrics = {};
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
if (url.includes('sonarr')) {
sonarrMetrics[url] = metrics;
} else if (url.includes('radarr')) {
radarrMetrics[url] = metrics;
}
}
// Aggregate metrics for each service
const aggregateMetrics = (metricsMap, configured) => {
const values = Object.values(metricsMap);
if (values.length === 0) {
// Return default metrics if configured but no events yet
return configured ? {
enabled: true,
eventsReceived: 0,
pollsSkipped: 0,
lastEvent: null
} : null;
}
return {
enabled: true,
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
lastEvent: values.reduce((latest, m) => {
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
}, 0)
};
};
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
@@ -796,7 +859,11 @@ router.get('/status', requireAuth, (req, res) => {
lastPoll: getLastPollTimings()
},
cache: cacheStats,
clients: getActiveClients()
clients: getActiveClients(),
webhooks: {
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
@@ -879,6 +946,8 @@ router.get('/stream', requireAuth, async (req, res) => {
await pollAllServices();
}
console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`);
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
@@ -889,6 +958,10 @@ router.get('/stream', requireAuth, async (req, res) => {
const radarrTagsData = cache.get('poll:radarr-tags') || [];
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
console.log(`[SSE] Data sizes - SAB queue: ${sabQueueData.slots?.length || 0}, SAB history: ${sabHistoryData.slots?.length || 0}, qBit: ${qbittorrentTorrents.length}`);
console.log(`[SSE] Sonarr queue: ${sonarrQueueData.records?.length || 0}, history: ${sonarrHistoryData.records?.length || 0}`);
console.log(`[SSE] Radarr queue: ${radarrQueueData.records?.length || 0}, history: ${radarrHistoryData.records?.length || 0}`);
const sabnzbdQueue = { data: { queue: sabQueueData } };
const sabnzbdHistory = { data: { history: sabHistoryData } };
const sonarrQueue = { data: sonarrQueueData };
@@ -930,18 +1003,107 @@ router.get('/stream', requireAuth, async (req, res) => {
}
// SABnzbd queue
let sabSlotsChecked = 0;
let sabSlotsMatched = 0;
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
for (const slot of sabnzbdQueue.data.queue.slots) {
const nzbName = slot.filename || slot.nzbname;
if (!nzbName) continue;
sabSlotsChecked++;
const slotState = getSlotStatusAndSpeed(slot);
const nzbNameLower = nzbName.toLowerCase();
const sonarrMatch = sonarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
// Normalize SAB name (dots to spaces) for better matching
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
// Try to match by downloadId first (most reliable)
const sabDownloadId = slot.nzo_id || slot.id;
let sonarrMatch = sabDownloadId ? sonarrQueue.data.records.find(r => r.downloadId === sabDownloadId) : null;
let radarrMatch = sabDownloadId ? radarrQueue.data.records.find(r => r.downloadId === sabDownloadId) : null;
// Also check HISTORY by downloadId
if (!sonarrMatch && sabDownloadId) {
sonarrMatch = sonarrHistory.data.records.find(r => r.downloadId === sabDownloadId);
}
if (!radarrMatch && sabDownloadId) {
radarrMatch = radarrHistory.data.records.find(r => r.downloadId === sabDownloadId);
}
// Fallback: Check by title matching
if (!sonarrMatch) {
sonarrMatch = sonarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
// Also check HISTORY (completed downloads) if no queue match
if (!sonarrMatch) {
sonarrMatch = sonarrHistory.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
if (!radarrMatch) {
radarrMatch = radarrHistory.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
// Debug first 5 items - show matches and non-matches
if (sabSlotsChecked <= 5) {
if (sonarrMatch) {
const source = sonarrQueue.data.records.includes(sonarrMatch) ? 'queue' : 'history';
const matchType = (sonarrMatch.downloadId === sabDownloadId) ? 'downloadId' : 'title';
console.log(`[SSE] ✓ Sonarr ${source} ${matchType} match: SAB:"${nzbNameLower.substring(0, 40)}" → Sonarr:"${(sonarrMatch.title || sonarrMatch.sourceTitle || '').substring(0, 40)}"`);
} else if (radarrMatch) {
const source = radarrQueue.data.records.includes(radarrMatch) ? 'queue' : 'history';
const matchType = (radarrMatch.downloadId === sabDownloadId) ? 'downloadId' : 'title';
console.log(`[SSE] ✓ Radarr ${source} ${matchType} match: SAB:"${nzbNameLower.substring(0, 40)}" → Radarr:"${(radarrMatch.title || radarrMatch.sourceTitle || '').substring(0, 40)}"`);
} else {
console.log(`[SSE] ✗ No match for SAB: "${nzbNameLower.substring(0, 60)}"`);
// Show counts
console.log(`[SSE] Queue: ${sonarrQueue.data.records.length}, History: ${sonarrHistory.data.records.length}`);
// Show Sonarr queue titles
if (sonarrQueue.data.records.length > 0) {
const queueTitles = sonarrQueue.data.records.slice(0, 3).map(r => (r.title || r.sourceTitle || 'NO_TITLE').substring(0, 40));
console.log(`[SSE] Queue titles: ${queueTitles.join(' | ')}`);
}
// Show history titles if there are any
if (sonarrHistory.data.records.length > 0) {
const histTitles = sonarrHistory.data.records.slice(0, 3).map(r => {
const title = (r.title || r.sourceTitle || 'NO_TITLE').substring(0, 35);
const dlId = r.downloadId ? r.downloadId.substring(0, 15) : 'no-dl-id';
return `${title}[${dlId}]`;
});
console.log(`[SSE] History titles: ${histTitles.join(' | ')}`);
}
// Also check if SAB slots have nzo_id we could use
if (slot.nzo_id) {
console.log(`[SSE] SAB nzo_id: ${slot.nzo_id.substring(0, 20)}...`);
}
}
}
if (sonarrMatch && sonarrMatch.seriesId) {
sabSlotsMatched++;
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const allTags = extractAllTags(series.tags, sonarrTagMap);
@@ -957,11 +1119,9 @@ router.get('/stream', requireAuth, async (req, res) => {
}
}
const radarrMatch = radarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
// Handle Radarr match (radarrMatch already declared above)
if (radarrMatch && radarrMatch.movieId) {
sabSlotsMatched++;
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const allTags = extractAllTags(movie.tags, radarrTagMap);
@@ -1094,6 +1254,11 @@ router.get('/stream', requireAuth, async (req, res) => {
}
// Write SSE event
console.log(`[SSE] SAB matching: ${sabSlotsChecked} checked, ${sabSlotsMatched} matched to Sonarr/Radarr`);
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
if (userDownloads.length > 0) {
console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`);
}
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
} catch (err) {
console.error('[SSE] Error building payload:', sanitizeError(err));
+47 -14
View File
@@ -4,7 +4,16 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
// Helper to get first Radarr instance (for notification proxy routes)
function getFirstRadarrInstance() {
const instances = getRadarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
@@ -60,12 +69,17 @@ router.get('/movies', async (req, res) => {
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
@@ -120,12 +134,21 @@ router.delete('/notifications/:id', async (req, res) => {
// POST /api/radarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Radarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Radarr] Test response status:', error.response.status);
console.error('[Radarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstRadarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Radarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.RADARR_URL}/api/v3/notification`,
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Radarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Radarr] Response status:', error.response.status);
console.error('[Radarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
+47 -14
View File
@@ -4,7 +4,16 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
// Helper to get first Sonarr instance (for notification proxy routes)
function getFirstSonarrInstance() {
const instances = getSonarrInstances();
if (!instances || instances.length === 0) {
return null;
}
return instances[0];
}
router.use(requireAuth);
@@ -60,12 +69,17 @@ router.get('/series', async (req, res) => {
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to fetch notifications:', error.message);
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
@@ -120,12 +134,21 @@ router.delete('/notifications/:id', async (req, res) => {
// POST /api/sonarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': instance.apiKey }
});
res.json(response.data);
} catch (error) {
console.error('[Sonarr] Failed to test notification:', error.message);
if (error.response) {
console.error('[Sonarr] Test response status:', error.response.status);
console.error('[Sonarr] Test response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
const instance = getFirstSonarrInstance();
if (!instance) {
return res.status(503).json({ error: 'Sonarr not configured' });
}
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
headers: { 'X-Api-Key': instance.apiKey }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'method', value: 1 },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onImport: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
onApplicationUpdate: false,
onManualInteractionRequired: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
`${instance.url}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.SONARR_URL}/api/v3/notification`,
`${instance.url}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
{ headers: { 'X-Api-Key': instance.apiKey } }
);
res.json(response.data);
}
} catch (error) {
console.error('[Sonarr] Failed to configure webhook:', error.message);
if (error.response) {
console.error('[Sonarr] Response status:', error.response.status);
console.error('[Sonarr] Response data:', error.response.data);
}
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
+14 -6
View File
@@ -247,10 +247,14 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Sonarr"), not the configured name
// Update metrics for all Sonarr instances since we can't reliably match
const sonarrInstances = getSonarrInstances();
const instance = sonarrInstances.find(i => i.name === instanceName);
if (instance) {
cache.updateWebhookMetrics(instance.url);
if (sonarrInstances.length > 0) {
for (const inst of sonarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${sonarrInstances.length} Sonarr instance(s)`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
@@ -296,10 +300,14 @@ router.post('/radarr', webhookLimiter, (req, res) => {
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Radarr"), not the configured name
// Update metrics for all Radarr instances since we can't reliably match
const radarrInstances = getRadarrInstances();
const instance = radarrInstances.find(i => i.name === instanceName);
if (instance) {
cache.updateWebhookMetrics(instance.url);
if (radarrInstances.length > 0) {
for (const inst of radarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${radarrInstances.length} Radarr instance(s)`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
+3 -2
View File
@@ -51,8 +51,9 @@ const arrRetrieverRegistry = {
}
const retriever = new RetrieverClass(config);
this.retrievers.set(config.id, retriever);
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
const uniqueKey = `${config.type}:${config.id}`;
this.retrievers.set(uniqueKey, retriever);
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
} catch (error) {
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
}
+3 -2
View File
@@ -63,8 +63,9 @@ class DownloadClientRegistry {
}
const client = new ClientClass(config);
this.clients.set(config.id, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
const uniqueKey = `${config.type}:${config.id}`;
this.clients.set(uniqueKey, client);
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
} catch (error) {
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
}