Compare commits

..

75 Commits

Author SHA1 Message Date
gronod 1f10414498 Update CHANGELOG for v1.5.5
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m8s
Create Release / release (push) Successful in 41s
Build and Push Docker Image / build (push) Successful in 21s
Docs Check / Markdown lint (push) Successful in 32s
CI / Security audit (push) Successful in 1m13s
CI / Tests & coverage (push) Failing after 1m24s
Docs Check / Mermaid diagram parse check (push) Successful in 1m49s
2026-05-20 01:13:01 +01:00
gronod 1e3926b206 Bump version to 1.5.5
Build and Push Docker Image / build (push) Successful in 40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 47s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Failing after 1m10s
2026-05-20 01:11:22 +01:00
gronod 5fde69fcf5 Add speed formatting to display appropriate units (KB/s, MB/s)
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 31s
CI / Security audit (push) Successful in 54s
CI / Tests & coverage (push) Failing after 1m5s
2026-05-20 01:07:52 +01:00
gronod a562cfe9aa Add logging to debug active download identification and speed
Build and Push Docker Image / build (push) Successful in 29s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Failing after 1m18s
2026-05-20 01:00:25 +01:00
gronod 8549746721 Apply overall SABnzbd speed to active download only
Build and Push Docker Image / build (push) Successful in 34s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 39s
CI / Security audit (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m13s
2026-05-20 00:58:38 +01:00
gronod 63fc370262 Remove speed from SABnzbd downloads - API doesn't provide per-download speed
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Failing after 1m8s
2026-05-20 00:56:54 +01:00
gronod 6362441dd5 Add logging to debug SABnzbd speed field in slot data
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 52s
CI / Security audit (push) Successful in 1m9s
CI / Tests & coverage (push) Successful in 1m22s
2026-05-20 00:54:26 +01:00
gronod 76f9e87b44 Add logging to investigate SABnzbd slot structure for speed field
Build and Push Docker Image / build (push) Successful in 35s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m15s
CI / Tests & coverage (push) Successful in 1m27s
2026-05-20 00:51:12 +01:00
gronod 8c461de72a Hide speed when it is 0 to avoid displaying misleading 0 speed
Build and Push Docker Image / build (push) Successful in 38s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 44s
CI / Security audit (push) Successful in 1m7s
CI / Tests & coverage (push) Has been cancelled
2026-05-20 00:49:26 +01:00
gronod d11f11be69 Fix missing speed on SAB cards and remove incorrect missing pieces display
Build and Push Docker Image / build (push) Successful in 16s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 29s
CI / Security audit (push) Successful in 59s
CI / Tests & coverage (push) Successful in 59s
2026-05-20 00:47:07 +01:00
gronod 05d11975e6 Reduce card logo size to 32x32
Build and Push Docker Image / build (push) Successful in 41s
CI / Security audit (push) Successful in 57s
CI / Tests & coverage (push) Successful in 1m8s
2026-05-20 00:41:04 +01:00
gronod cd3480c0ce Fix logo positioning by adding position: relative to download-card
Build and Push Docker Image / build (push) Successful in 41s
CI / Security audit (push) Successful in 1m3s
CI / Tests & coverage (push) Successful in 1m11s
2026-05-20 00:39:11 +01:00
gronod 712c98d817 Move card logo to bottom right with absolute positioning, fix duplication
Build and Push Docker Image / build (push) Successful in 23s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 41s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m18s
2026-05-20 00:37:01 +01:00
gronod ff7ace9f4f Fix duplicate icon and user tag on page reload by adding class and duplicate check
Build and Push Docker Image / build (push) Successful in 41s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 50s
CI / Security audit (push) Successful in 1m28s
CI / Tests & coverage (push) Successful in 1m44s
2026-05-20 00:29:44 +01:00
gronod 73500751a0 Increase download client logo size in cards to 64x64px (4x), keep filter picker at 20x20px
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 48s
CI / Security audit (push) Successful in 1m10s
CI / Tests & coverage (push) Successful in 1m15s
2026-05-20 00:26:54 +01:00
gronod 82a9df134b Fix duplicate user tag and logo in download cards by removing old elements before updating
Build and Push Docker Image / build (push) Successful in 32s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 30s
CI / Security audit (push) Successful in 50s
CI / Tests & coverage (push) Successful in 1m9s
2026-05-20 00:23:17 +01:00
gronod 67fa79796b Add download client logo to download card with right-side positioning
Build and Push Docker Image / build (push) Successful in 20s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 36s
CI / Security audit (push) Successful in 1m4s
CI / Tests & coverage (push) Successful in 1m10s
2026-05-20 00:20:03 +01:00
gronod f06d945358 Update rtorrent.svg logo
Build and Push Docker Image / build (push) Successful in 48s
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m37s
2026-05-20 00:15:46 +01:00
gronod f5883d4929 Add download client logos to filter UI with fallback handling
Build and Push Docker Image / build (push) Successful in 30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m4s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-20 00:14:20 +01:00
gronod 80cf3eaa39 Fix filtering to use both client type and instanceId for unique identification
Build and Push Docker Image / build (push) Successful in 59s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m2s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m32s
2026-05-20 00:00:17 +01:00
gronod 1ab7e52167 Use index-based unique identifiers for download client selection to prevent cross-selection
Build and Push Docker Image / build (push) Successful in 28s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 1m32s
2026-05-19 23:56:05 +01:00
gronod 544c168b82 Fix duplicate checkbox ID issue causing cross-selection between clients
Build and Push Docker Image / build (push) Successful in 26s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 48s
CI / Security audit (push) Successful in 1m21s
CI / Tests & coverage (push) Successful in 1m26s
2026-05-19 23:51:57 +01:00
gronod 747a14ebd3 Fix double-toggling issue in download client filter
Build and Push Docker Image / build (push) Successful in 1m15s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m0s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m38s
2026-05-19 23:48:29 +01:00
gronod 49d66c07ee Update ARCHITECTURE.md, bump version to 1.5.4, add CHANGELOG entry
CI / Security audit (push) Failing after 23s
Build and Push Docker Image / build (push) Successful in 52s
Docs Check / Markdown lint (push) Successful in 58s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m45s
2026-05-19 23:45:37 +01:00
gronod be791ed044 Add multi-select download client filter with client type display
Build and Push Docker Image / build (push) Successful in 24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 45s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-19 23:41:43 +01:00
gronod 7195a09562 Fix SABnzbd size and speed fields in SSE response
Build and Push Docker Image / build (push) Successful in 37s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 52s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Successful in 1m49s
2026-05-19 23:34:24 +01:00
gronod 720de6688b Add download client ordering and filtering to active downloads list
Build and Push Docker Image / build (push) Successful in 22s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m5s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m44s
2026-05-19 23:29:38 +01:00
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
gronod 5ab8cc96a3 Merge branch 'develop'
Create Release / release (push) Successful in 18s
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 1m17s
CI / Tests & coverage (push) Successful in 1m30s
2026-05-19 20:27:26 +01:00
gronod a7363fcb3a v1.5.2: Build and deploy React client with Webhooks Configuration panel
Build and Push Docker Image / build (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m6s
CI / Security audit (push) Successful in 1m29s
CI / Tests & coverage (push) Successful in 1m46s
2026-05-19 20:27:11 +01:00
gronod d06e24dbb6 feat(webhooks): display webhook statistics (events received, polls skipped, last event) in status panel
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m11s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 19:18:29 +01:00
gronod 6df94e5ad2 Merge branch 'develop' into main — release 1.5.1
CI / Security audit (push) Successful in 1m31s
CI / Tests & coverage (push) Successful in 1m42s
2026-05-19 19:08:03 +01:00
gronod 015e07ae7a Merge hotfix: webhook routing + version 1.5.1
Docs Check / Markdown lint (push) Successful in 34s
Build and Push Docker Image / build (push) Successful in 1m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Security audit (push) Successful in 1m52s
Docs Check / Mermaid diagram parse check (push) Successful in 2m5s
CI / Tests & coverage (push) Successful in 2m10s
2026-05-19 19:07:12 +01:00
gronod 740b03ac85 Merge branch 'develop' into main — release 1.5.0a
Build and Push Docker Image / build (push) Successful in 1m1s
Create Release / release (push) Successful in 25s
CI / Security audit (push) Successful in 1m38s
CI / Tests & coverage (push) Successful in 1m35s
2026-05-19 18:52:02 +01:00
gronod 575688dab7 Merge branch 'develop' into main — release 1.5.0
Create Release / release (push) Successful in 18s
Build and Push Docker Image / build (push) Successful in 39s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m24s
2026-05-19 18:42:34 +01:00
gronod 84658102e0 Merge branch 'develop'
Create Release / release (push) Successful in 12s
Build and Push Docker Image / build (push) Successful in 32s
CI / Security audit (push) Successful in 42s
CI / Tests & coverage (push) Successful in 58s
Implements PDCA feature
2026-05-19 14:58:45 +01:00
gronod ae9e877445 Merge branch 'main' of https://git.i3omb.com/Gandalf/sofarr
Create Release / release (push) Successful in 30s
CI / Security audit (push) Successful in 1m38s
CI / Tests & coverage (push) Successful in 1m52s
2026-05-19 09:07:59 +01:00
gronod 853b205c46 Merge develop: Add MIT copyright headers 2026-05-19 09:07:51 +01:00
Gandalf 65b9f0f395 Merge pull request 'fix: documentation update' (#15) from develop into main
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 47s
CI / Tests & coverage (push) Successful in 54s
Reviewed-on: #15
2026-05-18 08:02:30 +01:00
gronod 20dfe06866 Merge branch 'develop'
CI / Security audit (push) Successful in 1m33s
CI / Tests & coverage (push) Successful in 1m40s
Create Release / release (push) Successful in 9s
Build and Push Docker Image / build (push) Successful in 18s
2026-05-18 06:35:46 +01:00
gronod 3bb9e936c3 release: v1.3.0
Build and Push Docker Image / build (push) Successful in 50s
CI / Security audit (push) Successful in 2m34s
CI / Tests & coverage (push) Successful in 2m3s
2026-05-17 23:29:12 +01:00
26 changed files with 1900 additions and 110 deletions
+43 -1
View File
@@ -460,6 +460,21 @@ When a browser opens `GET /api/dashboard/stream`:
The browser's native `EventSource` API handles reconnection automatically on network interruption.
**SSE Payload Structure**
```javascript
{
user: string, // Username
isAdmin: boolean, // Admin flag
downloads: DownloadObject[], // Matched download objects (see Section 5.4)
downloadClients: { // Configured download clients for ordering/filtering
id: string, // Instance identifier
name: string, // Instance display name
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
}[]
}
```
### 5.4 Download Matching Pipeline
For each connected user the server:
@@ -495,6 +510,14 @@ Users are matched to downloads via Sonarr/Radarr tags:
1. **Exact match** — tag label (lowercased) === username (lowercased).
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
#### Client ordering and filtering
Matched download objects include `client`, `instanceId`, and `instanceName` fields. The frontend:
1. Receives a `downloadClients` array from the SSE payload with all configured clients in configuration order
2. Displays a multi-select filter allowing users to choose which clients to view
3. Sorts downloads by client order (downloads from the first configured client appear first)
4. Filters downloads to show only those from selected client instances
#### Matched download object fields
| Field | Type | Description |
@@ -523,6 +546,9 @@ Users are matched to downloads via Sonarr/Radarr tags:
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
| `client` | string | Download client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent') |
| `instanceId` | string | Instance identifier matching the configured client ID |
| `instanceName` | string | Instance display name from configuration |
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
@@ -683,7 +709,7 @@ stateDiagram-v2
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data |
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `renderDownloads()` | Diff-based card rendering (create/update/remove); filters by selected download clients; sorts by client order |
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
@@ -698,6 +724,22 @@ stateDiagram-v2
- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
#### Download Client Filter
The Active Downloads tab includes a multi-select dropdown filter that allows users to:
- View all download clients with their type displayed as "Client Name (type)"
- Select multiple clients to filter the downloads list
- Use "Select All" / "Deselect All" buttons for bulk operations
- Persist selection across sessions via localStorage
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
Related functions:
- `initDownloadClientFilter()` — Sets up dropdown toggle, click-outside handler, Select/Deselect All buttons
- `updateDownloadClientFilter()` — Populates checkbox list with client name + type badges
- `toggleClientSelection()` — Updates selection array and localStorage
- `updateSelectedCountDisplay()` — Updates button text to show "All clients" / "1 selected" / "N selected"
---
## 8. Directory Structure
+53
View File
@@ -6,6 +6,59 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
---
## [1.5.5] - 2026-05-20
### Added
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
### Fixed
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
---
## [1.5.4] - 2026-05-19
### Added
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
### Fixed
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
---
## [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
+38
View File
@@ -459,3 +459,41 @@ body {
.trigger-value.inactive {
color: #999;
}
.webhook-stats {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.webhook-stats-title {
color: #999;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 10px;
}
.webhook-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.webhook-stat {
display: flex;
flex-direction: column;
gap: 3px;
}
.webhook-stat-label {
color: #999;
font-size: 0.8rem;
}
.webhook-stat-value {
color: #333;
font-size: 0.95rem;
font-weight: 500;
}
+94 -14
View File
@@ -11,8 +11,9 @@ function App() {
const [error, setError] = useState(null);
const [sessions, setSessions] = useState([]);
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
const [webhookMetrics, setWebhookMetrics] = useState(null);
const [webhookLoading, setWebhookLoading] = useState(false);
useEffect(() => {
@@ -72,43 +73,82 @@ function App() {
return new Date(dateString).toLocaleString();
};
const formatTimeAgo = (timestamp) => {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const fetchWebhookMetrics = async () => {
try {
const response = await axios.get('/api/dashboard/webhook-metrics');
setWebhookMetrics(response.data);
return response.data;
} catch (err) {
// Not fatal — stats just won't display
return null;
}
};
const fetchWebhookStatus = async () => {
try {
// Fetch metrics in parallel with notification status
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrResponse = await axios.get('/api/sonarr/notifications');
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
setSonarrWebhook({
enabled: !!sonarrSofarr,
triggers: sonarrSofarr ? {
sonarrEnabled = !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
});
};
}
} catch (err) {
// Sonarr not configured or not accessible
setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrResponse = await axios.get('/api/radarr/notifications');
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
setRadarrWebhook({
enabled: !!radarrSofarr,
triggers: radarrSofarr ? {
radarrEnabled = !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
});
};
}
} catch (err) {
// Radarr not configured or not accessible
setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
}
const metrics = await metricsPromise;
// Attach per-instance stats from global metrics.
// The instances object is keyed by instance URL; we pick the first
// sonarr/radarr entry by matching env-configured URLs.
const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats });
setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats });
} catch (err) {
console.error('Failed to fetch webhook status:', err);
}
@@ -147,6 +187,7 @@ function App() {
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
if (sonarrSofarr) {
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
await fetchWebhookStatus();
alert('Sonarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Sonarr.');
@@ -166,6 +207,7 @@ function App() {
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
if (radarrSofarr) {
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
await fetchWebhookStatus();
alert('Radarr webhook test sent successfully!');
} else {
alert('Sofarr webhook not configured for Radarr.');
@@ -342,6 +384,25 @@ function App() {
</div>
</div>
)}
{sonarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div>
<div className="webhook-instance">
<h3>Radarr</h3>
@@ -388,6 +449,25 @@ function App() {
</div>
</div>
)}
{radarrWebhook.stats && (
<div className="webhook-stats">
<div className="webhook-stats-title">Statistics</div>
<div className="webhook-stats-grid">
<div className="webhook-stat">
<span className="webhook-stat-label">Events Received</span>
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Polls Skipped</span>
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
</div>
<div className="webhook-stat">
<span className="webhook-stat-label">Last Event</span>
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
+5 -1
View File
@@ -44,13 +44,17 @@ services:
volumes:
# Persistent volume for token store and log file
- sofarr-data:/app/data
# Mount code for development (comment out in production)
- ./server:/app/server
- ./public:/app/public
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
# - /path/to/your/server.crt:/app/certs/server.crt:ro
# - /path/to/your/server.key:/app/certs/server.key:ro
# Run as the built-in non-root 'node' user (UID/GID 1000)
user: "1000:1000"
# Read-only root filesystem; only the data volume is writable
read_only: true
# Comment out for development when mounting code volumes
# read_only: true
tmpfs:
- /tmp # Node.js needs a writable /tmp
security_opt:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.5.1",
"version": "1.5.5",
"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": {
+745 -22
View File
@@ -1,11 +1,35 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
let currentUser = null;
let downloads = [];
let downloadClients = []; // List of download clients from server (for ordering/filtering)
let selectedDownloadClients = []; // Array of selected client IDs for multi-select filter
let isAdmin = false;
let showAll = false;
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
const SPLASH_MIN_MS = 1200; // minimum splash display time
// Migration from old single-select to new multi-select format
(function migrateDownloadClientFilter() {
const oldSelection = localStorage.getItem('sofarr-download-client');
if (oldSelection && oldSelection !== 'all') {
try {
selectedDownloadClients = [oldSelection];
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
localStorage.removeItem('sofarr-download-client');
} catch (e) {
console.error('[Migration] Failed to migrate download client filter:', e);
}
} else {
try {
const newSelection = localStorage.getItem('sofarr-download-clients');
selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
} catch (e) {
console.error('[Migration] Failed to load download client filter:', e);
selectedDownloadClients = [];
}
}
})();
// History section state
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
let historyRefreshHandle = null;
@@ -30,6 +54,8 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeSwitcher();
initTabs();
initHistoryControls();
initDownloadClientFilter();
initWebhooks();
loadAppVersion();
document.getElementById('login-form').addEventListener('submit', handleLogin);
@@ -117,6 +143,11 @@ function startSSE() {
currentUser = data.user;
isAdmin = !!data.isAdmin;
downloads = data.downloads;
// Store download clients and update filter dropdown
if (data.downloadClients) {
downloadClients = data.downloadClients;
updateDownloadClientFilter();
}
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
hideError();
@@ -299,8 +330,11 @@ function showDashboard() {
// Always start with status panel hidden (guards against stale display value on re-login)
const sp = document.getElementById('status-panel');
sp.style.display = 'none';
sp.innerHTML = '';
// Also hide webhooks-section to keep them in sync (both show/hide together)
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.style.display = 'none';
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
// Initialise days input from saved value
const daysInput = document.getElementById('history-days');
if (daysInput) daysInput.value = historyDays;
@@ -348,28 +382,48 @@ function formatEpisodeInfo(episodes) {
function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
if (downloads.length === 0) {
// Filter downloads by selected clients
let filteredDownloads = downloads;
if (selectedDownloadClients.length > 0) {
// Map indices to client objects, then filter by both client type and instanceId
const selectedClients = selectedDownloadClients.map(idx => downloadClients[idx]).filter(Boolean);
filteredDownloads = downloads.filter(d =>
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
);
}
// Sort downloads by client order (matching the order in downloadClients)
if (downloadClients.length > 0) {
const clientOrder = new Map(downloadClients.map((c, idx) => [c.id, idx]));
filteredDownloads = [...filteredDownloads].sort((a, b) => {
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
return orderA - orderB;
});
}
if (filteredDownloads.length === 0) {
noDownloads.style.display = 'block';
downloadsList.innerHTML = '';
return;
}
noDownloads.style.display = 'none';
// Get existing cards
const existingCards = new Map();
downloadsList.querySelectorAll('.download-card').forEach(card => {
existingCards.set(card.dataset.id, card);
});
// Track which downloads we've processed
const processedIds = new Set();
downloads.forEach(download => {
filteredDownloads.forEach(download => {
const id = download.title;
processedIds.add(id);
const existingCard = existingCards.get(id);
if (existingCard) {
// Update existing card
@@ -380,7 +434,7 @@ function renderDownloads() {
downloadsList.appendChild(card);
}
});
// Remove cards for downloads that no longer exist
existingCards.forEach((card, id) => {
if (!processedIds.has(id)) {
@@ -390,6 +444,78 @@ function renderDownloads() {
}
function updateDownloadCard(card, download) {
// Remove old header-right container if it exists
const oldRightSide = card.querySelector('.download-header-right');
if (oldRightSide) {
oldRightSide.remove();
}
// Remove old user badges directly in header
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
oldBadges.forEach(badge => badge.remove());
// Remove old client logo from header (old structure)
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
if (oldLogoInHeader) {
oldLogoInHeader.remove();
}
// Remove old client logo from card (new structure) if it exists
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
if (oldLogoInCard) {
oldLogoInCard.remove();
}
// Add new right-side container with user badge only
const header = card.querySelector('.download-header');
if (header && !header.querySelector('.download-header-right')) {
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
const matched = download.tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
rightSide.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
rightSide.appendChild(badge);
}
} else if (download.matchedUserTag) {
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = download.matchedUserTag;
rightSide.appendChild(matchedBadge);
}
header.appendChild(rightSide);
}
// Add client logo to card (positioned at bottom right via CSS)
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
const clientLogo = document.createElement('img');
clientLogo.className = 'download-client-logo';
clientLogo.src = `/images/clients/${download.client}.svg`;
clientLogo.alt = `${download.instanceName || download.client} icon`;
clientLogo.title = download.instanceName || download.client;
clientLogo.onerror = () => {
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
};
clientLogoWrapper.appendChild(clientLogo);
card.appendChild(clientLogoWrapper);
}
// Update status
const statusEl = card.querySelector('.download-status');
if (statusEl && statusEl.textContent !== download.status) {
@@ -528,7 +654,7 @@ function createDownloadCard(download) {
const header = document.createElement('div');
header.className = 'download-header';
const type = document.createElement('span');
type.className = `download-type ${download.type}`;
if (download.type === 'series') {
@@ -541,11 +667,11 @@ function createDownloadCard(download) {
} else {
type.textContent = download.type;
}
const status = document.createElement('span');
status.className = `download-status ${download.status}`;
status.textContent = download.status;
header.appendChild(type);
header.appendChild(status);
@@ -565,6 +691,58 @@ function createDownloadCard(download) {
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
header.appendChild(blBtn);
}
// Right side container for user badge only
const rightSide = document.createElement('div');
rightSide.className = 'download-header-right';
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
// In showAll mode: render all tags classified by whether they match an Emby user.
// Unmatched (no known Emby user) → amber, leftmost.
// Matched → show Emby display name in accent colour, rightmost.
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
const matched = download.tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
rightSide.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
rightSide.appendChild(badge);
}
} else if (download.matchedUserTag) {
// Normal (non-showAll) view: show only the current user's matched tag
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = download.matchedUserTag;
rightSide.appendChild(matchedBadge);
}
header.appendChild(rightSide);
// Add client logo to card (positioned at bottom right via CSS)
if (download.client) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
const clientLogo = document.createElement('img');
clientLogo.className = 'download-client-logo';
clientLogo.src = `/images/clients/${download.client}.svg`;
clientLogo.alt = `${download.instanceName || download.client} icon`;
clientLogo.title = download.instanceName || download.client;
clientLogo.onerror = () => {
// Fallback to text if image fails to load
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
};
clientLogoWrapper.appendChild(clientLogo);
card.appendChild(clientLogoWrapper);
}
const title = document.createElement('h3');
title.className = 'download-title';
@@ -622,6 +800,26 @@ function createDownloadCard(download) {
matchedBadge.textContent = download.matchedUserTag;
header.appendChild(matchedBadge);
}
// Add client logo
if (download.client) {
const clientLogoWrapper = document.createElement('span');
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
const clientLogo = document.createElement('img');
clientLogo.className = 'download-client-logo';
clientLogo.src = `/images/clients/${download.client}.svg`;
clientLogo.alt = `${download.instanceName || download.client} icon`;
clientLogo.title = download.instanceName || download.client;
clientLogo.onerror = () => {
// Fallback to text if image fails to load
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
clientLogoWrapper.classList.add('fallback');
};
clientLogoWrapper.appendChild(clientLogo);
header.appendChild(clientLogoWrapper);
}
const details = document.createElement('div');
details.className = 'download-details';
@@ -667,8 +865,8 @@ function createDownloadCard(download) {
progressText.textContent = download.progress + '%';
valueDiv.appendChild(progressText);
// Missing pieces text
if (missingMb > 0 && totalMb > 0) {
// Missing pieces text (only for torrent clients like qBittorrent)
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) {
const missingText = document.createElement('span');
missingText.className = 'missing-text';
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
@@ -680,8 +878,8 @@ function createDownloadCard(download) {
details.appendChild(progressItem);
}
if (download.speed) {
const speed = createDetailItem('Speed', download.speed);
if (download.speed && download.speed > 0) {
const speed = createDetailItem('Speed', formatSpeed(download.speed));
details.appendChild(speed);
}
@@ -738,6 +936,21 @@ function createDownloadCard(download) {
return card;
}
function formatSpeed(bytesPerSecond) {
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
@@ -768,12 +981,26 @@ const STATUS_REFRESH_MS = 5000;
async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
const webhooksSection = document.getElementById('webhooks-section');
if (panel.style.display !== 'none') {
// Close both panels (webhooks is a sibling, hide it too)
panel.style.display = 'none';
if (webhooksSection) webhooksSection.style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
return;
}
// Open status panel and webhooks section (siblings)
panel.style.display = 'block';
// Show webhooks section for admin users (collapsed by default)
if (webhooksSection && isAdmin) {
webhooksSection.style.display = 'block';
webhookSectionExpanded = false;
document.getElementById('webhooks-content').style.display = 'none';
document.getElementById('webhooks-toggle').classList.remove('expanded');
await fetchWebhookStatus();
} else if (webhooksSection) {
webhooksSection.style.display = 'none';
}
await refreshStatusPanel();
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
@@ -781,26 +1008,34 @@ async function toggleStatusPanel() {
function closeStatusPanel() {
document.getElementById('status-panel').style.display = 'none';
const webhooksSection = document.getElementById('webhooks-section');
if (webhooksSection) webhooksSection.style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
}
async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
const contentDiv = document.getElementById('status-content');
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
if (!panel || panel.style.display === 'none') return;
console.log('[Status] Refreshing status panel...');
try {
const res = await fetch('/api/dashboard/status');
if (!res.ok) throw new Error('Failed to fetch status');
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
const data = await res.json();
console.log('[Status] Got status data, rendering...');
renderStatusPanel(data, panel);
} catch (err) {
console.error('[Status] Error fetching status:', err);
// Don't overwrite panel on transient error during auto-refresh
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
}
}
}
function renderStatusPanel(data, panel) {
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
@@ -848,6 +1083,26 @@ function renderStatusPanel(data, panel) {
html += `</div>`;
// Webhook metrics card (admin only)
if (isAdmin && data.webhooks) {
const wh = data.webhooks;
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
const radarrEvents = wh.radarr?.eventsReceived || 0;
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
const radarrPolls = wh.radarr?.pollsSkipped || 0;
html += `
<div class="status-card">
<div class="status-card-title">Webhooks</div>
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
</div>`;
}
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
@@ -885,9 +1140,21 @@ function renderStatusPanel(data, panel) {
}
html += `</tbody></table></div></div>`;
panel.innerHTML = html;
// Render into status-content div, not the whole panel (preserves webhooks section)
const contentDiv = document.getElementById('status-content');
const panelCheck = document.getElementById('status-panel');
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
if (panelCheck) {
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
}
if (contentDiv) {
contentDiv.innerHTML = html;
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
} else {
console.error('[Status] contentDiv not found!');
}
// Wire close button — addEventListener avoids CSP inline handler restrictions
const closeBtn = panel.querySelector('#status-close-btn');
const closeBtn = document.getElementById('status-close-btn');
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
@@ -964,6 +1231,171 @@ function initHistoryControls() {
}
}
// =============================================================================
// Download Client Filter
// =============================================================================
function initDownloadClientFilter() {
const dropdownBtn = document.getElementById('download-client-dropdown-btn');
const dropdown = document.getElementById('download-client-dropdown');
const selectAllBtn = document.getElementById('download-client-select-all');
const deselectAllBtn = document.getElementById('download-client-deselect-all');
if (dropdownBtn && dropdown) {
// Toggle dropdown
dropdownBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
dropdownBtn.classList.toggle('open', isOpen);
dropdownBtn.setAttribute('aria-expanded', isOpen);
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !dropdownBtn.contains(e.target)) {
dropdown.classList.remove('open');
dropdownBtn.classList.remove('open');
dropdownBtn.setAttribute('aria-expanded', 'false');
}
});
// Close dropdown on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
dropdown.classList.remove('open');
dropdownBtn.classList.remove('open');
dropdownBtn.setAttribute('aria-expanded', 'false');
}
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectedDownloadClients = downloadClients.map((_, idx) => idx);
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateDownloadClientFilter();
renderDownloads();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectedDownloadClients = [];
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateDownloadClientFilter();
renderDownloads();
});
}
}
function updateDownloadClientFilter() {
const optionsContainer = document.getElementById('download-client-options');
if (!optionsContainer) return;
// Clear existing options
optionsContainer.innerHTML = '';
if (downloadClients.length === 0) {
optionsContainer.innerHTML = '<div class="download-client-empty">No clients available</div>';
return;
}
// Migrate old client.id values to indices
if (selectedDownloadClients.length > 0 && typeof selectedDownloadClients[0] === 'string') {
const migratedIndices = [];
selectedDownloadClients.forEach(clientId => {
const index = downloadClients.findIndex(c => c.id === clientId);
if (index !== -1) {
migratedIndices.push(index);
}
});
selectedDownloadClients = migratedIndices;
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
}
// Add checkboxes for each download client
downloadClients.forEach((client, index) => {
const option = document.createElement('div');
option.className = 'download-client-option';
const checkboxId = `download-client-checkbox-${index}`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'download-client-checkbox';
checkbox.value = index; // Use index as unique identifier
checkbox.checked = selectedDownloadClients.includes(index);
checkbox.id = checkboxId;
// Toggle selection when checkbox changes
checkbox.addEventListener('change', (e) => {
toggleClientSelection(index, e.target.checked);
});
// Add client icon
const iconWrapper = document.createElement('span');
iconWrapper.className = 'download-client-icon';
const iconImg = document.createElement('img');
iconImg.src = `/images/clients/${client.type}.svg`;
iconImg.alt = `${client.name} icon`;
iconImg.onerror = () => {
// Fallback to text if image fails to load
iconWrapper.textContent = client.type.charAt(0).toUpperCase();
iconWrapper.classList.add('fallback');
};
iconWrapper.appendChild(iconImg);
const label = document.createElement('label');
label.className = 'download-client-option-label';
label.htmlFor = checkboxId;
label.textContent = client.name;
const typeBadge = document.createElement('span');
typeBadge.className = 'download-client-type';
typeBadge.textContent = client.type;
option.appendChild(checkbox);
option.appendChild(iconWrapper);
option.appendChild(label);
option.appendChild(typeBadge);
optionsContainer.appendChild(option);
});
// Update button text
updateSelectedCountDisplay();
}
function toggleClientSelection(clientId, isSelected) {
if (isSelected) {
if (!selectedDownloadClients.includes(clientId)) {
selectedDownloadClients.push(clientId);
}
} else {
selectedDownloadClients = selectedDownloadClients.filter(id => id !== clientId);
}
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
updateSelectedCountDisplay();
renderDownloads();
}
function updateSelectedCountDisplay() {
const selectedText = document.getElementById('download-client-selected-text');
if (!selectedText) return;
if (selectedDownloadClients.length === 0) {
selectedText.textContent = 'All clients';
} else if (selectedDownloadClients.length === 1) {
const client = downloadClients[selectedDownloadClients[0]];
selectedText.textContent = client ? client.name : '1 selected';
} else {
selectedText.textContent = `${selectedDownloadClients.length} selected`;
}
}
function startHistoryRefresh() {
stopHistoryRefresh();
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
@@ -1150,3 +1582,294 @@ function createHistoryCard(item) {
card.appendChild(info);
return card;
}
// ===== Webhooks Configuration =====
let webhookSectionExpanded = false;
let webhookLoading = false;
let sonarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null };
let radarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null };
let webhookMetrics = null;
function initWebhooks() {
const webhooksSection = document.getElementById('webhooks-section');
if (!webhooksSection) return;
// Note: visibility is controlled by showDashboard() based on isAdmin
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
}
function toggleWebhookSection() {
webhookSectionExpanded = !webhookSectionExpanded;
const content = document.getElementById('webhooks-content');
const toggle = document.getElementById('webhooks-toggle');
content.style.display = webhookSectionExpanded ? '' : 'none';
toggle.classList.toggle('expanded', webhookSectionExpanded);
if (webhookSectionExpanded) {
fetchWebhookStatus();
}
}
async function fetchWebhookMetrics() {
try {
const res = await fetch('/api/dashboard/webhook-metrics');
if (!res.ok) return null;
return await res.json();
} catch (err) {
return null;
}
}
async function fetchWebhookStatus() {
const loadingEl = document.getElementById('webhook-loading');
loadingEl.style.display = '';
try {
// Fetch metrics in parallel
const metricsPromise = fetchWebhookMetrics();
// Fetch Sonarr notifications
let sonarrEnabled = false;
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (sonarrRes.ok) {
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
sonarrEnabled = !!sonarrSofarr;
if (sonarrSofarr) {
sonarrTriggers = {
onGrab: sonarrSofarr.onGrab,
onDownload: sonarrSofarr.onDownload,
onImport: sonarrSofarr.onImport,
onUpgrade: sonarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Sonarr not configured
}
// Fetch Radarr notifications
let radarrEnabled = false;
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (radarrRes.ok) {
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
radarrEnabled = !!radarrSofarr;
if (radarrSofarr) {
radarrTriggers = {
onGrab: radarrSofarr.onGrab,
onDownload: radarrSofarr.onDownload,
onImport: radarrSofarr.onImport,
onUpgrade: radarrSofarr.onUpgrade
};
}
}
} catch (err) {
// Radarr not configured
}
webhookMetrics = await metricsPromise;
// Find instance stats
const instanceEntries = webhookMetrics ? Object.entries(webhookMetrics.instances || {}) : [];
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
renderWebhookStatus();
} catch (err) {
console.error('Failed to fetch webhook status:', err);
} finally {
loadingEl.style.display = 'none';
}
}
function renderWebhookStatus() {
// Sonarr
const sonarrStatus = document.getElementById('sonarr-status');
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
const sonarrTriggers = document.getElementById('sonarr-triggers');
const sonarrStats = document.getElementById('sonarr-stats');
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
sonarrEnableBtn.style.display = sonarrWebhook.enabled ? 'none' : '';
sonarrTestBtn.style.display = sonarrWebhook.enabled ? '' : 'none';
sonarrTriggers.style.display = sonarrWebhook.enabled ? '' : 'none';
if (sonarrWebhook.enabled) {
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (sonarrWebhook.stats) {
sonarrStats.style.display = '';
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
} else {
sonarrStats.style.display = 'none';
}
// Radarr
const radarrStatus = document.getElementById('radarr-status');
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
const radarrTestBtn = document.getElementById('test-radarr-webhook');
const radarrTriggers = document.getElementById('radarr-triggers');
const radarrStats = document.getElementById('radarr-stats');
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
radarrEnableBtn.style.display = radarrWebhook.enabled ? 'none' : '';
radarrTestBtn.style.display = radarrWebhook.enabled ? '' : 'none';
radarrTriggers.style.display = radarrWebhook.enabled ? '' : 'none';
if (radarrWebhook.enabled) {
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
}
if (radarrWebhook.stats) {
radarrStats.style.display = '';
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
} else {
radarrStats.style.display = 'none';
}
}
function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ago';
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + 'h ago';
return Math.floor(hours / 24) + 'd ago';
}
async function enableSonarrWebhook() {
setWebhookLoading(true);
try {
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Sonarr webhook:', err);
alert('Failed to enable Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
async function enableRadarrWebhook() {
setWebhookLoading(true);
try {
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken || '' }
});
if (!res.ok) throw new Error('Failed to enable');
await fetchWebhookStatus();
} catch (err) {
console.error('Failed to enable Radarr webhook:', err);
alert('Failed to enable Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
async function testSonarrWebhook() {
setWebhookLoading(true);
try {
const sonarrRes = await fetch('/api/sonarr/notifications');
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
const sonarrData = await sonarrRes.json();
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/sonarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken || ''
},
body: JSON.stringify(sonarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
alert('Sonarr webhook test sent successfully!');
} catch (err) {
console.error('Failed to test Sonarr webhook:', err);
alert('Failed to test Sonarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
async function testRadarrWebhook() {
setWebhookLoading(true);
try {
const radarrRes = await fetch('/api/radarr/notifications');
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
const radarrData = await radarrRes.json();
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
const res = await fetch('/api/radarr/notifications/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken || ''
},
body: JSON.stringify(radarrSofarr)
});
if (!res.ok) throw new Error('Test failed');
await fetchWebhookStatus();
alert('Radarr webhook test sent successfully!');
} catch (err) {
console.error('Failed to test Radarr webhook:', err);
alert('Failed to test Radarr webhook. Check console for details.');
} finally {
setWebhookLoading(false);
}
}
function setWebhookLoading(loading) {
webhookLoading = loading;
document.getElementById('enable-sonarr-webhook').disabled = loading;
document.getElementById('enable-radarr-webhook').disabled = loading;
document.getElementById('test-sonarr-webhook').disabled = loading;
document.getElementById('test-radarr-webhook').disabled = loading;
document.getElementById('webhook-loading').style.display = loading ? '' : 'none';
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m256.1 2.6 142 217.2c91 139.2-4.7 289.6-142.1 289.6S22.8 358.9 113.9 219.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#4c90e8;stroke:#094491;stroke-width:3.1904"/><path d="M306.7 255.2c-77.6-31.7-118.4 49.2-105.4 87C229.5 424.4 335.8 445 414.2 308c0 0 .8 16.5 1.7 24.4 10.5 99.9-73.5 163.4-158.6 162.2s-111.1-34.3-136.2-72.3C80.4 360.6 90.5 260 145.2 212.9c62.5-51.6 131.2-24 161.5 42.3" style="fill-rule:evenodd;clip-rule:evenodd;fill:#094491"/><path d="M257.9 225.3c-87 2.1-103.5 102.3-79.4 145.3 33.5 59.7 84.3 71.2 153.8 49.1-39.7 43.2-121.2 54.6-176.5-7.1-38.1-42.4-41.4-101.6-15-151.1 26.5-49.4 79.2-63.2 117.1-36.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#83b8f9"/></svg>

After

Width:  |  Height:  |  Size: 786 B

+1
View File
@@ -0,0 +1 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="348.2829" x2="782.05951" y1="0" y2="786.48322"><stop offset="0" stop-color="#72b4f5"/><stop offset="1" stop-color="#356ebf"/></linearGradient><g fill="none" fill-rule="evenodd" transform="matrix(.97656268 0 0 .9765624 11.999908 12.000051)"><circle cx="512" cy="512" fill="url(#a)" r="496" stroke="#daefff" stroke-width="32"/><path d="m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z" fill="#fff"/><path d="m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z" fill="#c8e8ff"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linejoin="round" stroke-width="74" d="M200.4 39.3h598.1v437.8h161l-460.1 483L39.4 477h161z"/><path fill="#ffb300" fill-rule="evenodd" d="M200.4 39.3h598.1v437.8h161l-460.1 483-460-483h161z"/><path fill="#ffca28" fill-rule="evenodd" d="M499.4 960.2 201.1 39.4h596.7z"/><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linecap="round" stroke-linejoin="round" stroke-width="74" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-97.8h46v46h-46zm192.1 97.8v-344h100.1v97.4h146.1v246.6zm100.1-195.2h46v143.4h-46z"/><path fill="#0f0f0f" fill-rule="evenodd" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-51.8h46v-46h-46zm192.1 51.9v-344h100.1V597h146.1v246.6zm100.1-51.9h46V648.4h-46z"/></svg>

After

Width:  |  Height:  |  Size: 966 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

+83 -1
View File
@@ -68,7 +68,69 @@
</div>
</header>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<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>
@@ -82,6 +144,26 @@
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div class="downloads-header">
<div class="downloads-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label>
<div class="download-client-filter" id="download-client-filter">
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
<span id="download-client-selected-text">All clients</span>
<span class="dropdown-arrow"></span>
</button>
<div class="download-client-dropdown" id="download-client-dropdown">
<div class="download-client-dropdown-header">
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
</div>
<div class="download-client-options" id="download-client-options">
<!-- Options will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<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>
+488 -1
View File
@@ -36,6 +36,7 @@
--bg-gradient-end: #d4dee8;
/* Surfaces */
--background: #f5f7f9;
--surface: #ffffff;
--surface-alt: #f0f4f7;
@@ -98,6 +99,7 @@
[data-theme="dark"] {
--bg-gradient-start: #1a1a2e;
--bg-gradient-end: #16213e;
--background: #161622;
--surface: #1e1e2f;
--surface-alt: #2a2a3d;
--text-primary: #e0e0e0;
@@ -136,6 +138,7 @@
[data-theme="mono"] {
--bg-gradient-start: #222222;
--bg-gradient-end: #333333;
--background: #141414;
--surface: #1a1a1a;
--surface-alt: #252525;
--text-primary: #d0d0d0;
@@ -370,6 +373,7 @@ body {
align-items: flex-start;
transition: box-shadow 0.2s, background 0.3s;
background: var(--surface);
position: relative;
}
.download-card:hover {
@@ -659,6 +663,212 @@ body {
padding: 0;
}
/* Downloads header and controls */
.downloads-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.downloads-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.download-client-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.download-client-select {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.download-client-select:focus {
outline: none;
border-color: var(--accent);
}
/* Multi-select dropdown container */
.download-client-filter {
position: relative;
display: inline-block;
}
.download-client-dropdown-btn {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transition: background 0.15s, border-color 0.15s;
}
.download-client-dropdown-btn:hover {
background: var(--hover-bg);
}
.download-client-dropdown-btn:focus {
outline: none;
border-color: var(--accent);
}
.download-client-dropdown-btn .dropdown-arrow {
font-size: 0.75rem;
transition: transform 0.2s;
}
.download-client-dropdown-btn.open .dropdown-arrow {
transform: rotate(180deg);
}
.download-client-count {
background: var(--accent);
color: white;
padding: 1px 6px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
/* Dropdown panel */
.download-client-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: 300px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
display: none;
}
.download-client-dropdown.open {
display: block;
}
/* Dropdown header with Select All/Deselect All buttons */
.download-client-dropdown-header {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.download-client-dropdown-btn-small {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface-alt);
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.download-client-dropdown-btn-small:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
/* Client option row */
.download-client-option {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background 0.15s;
}
.download-client-option:hover {
background: var(--hover-bg);
}
.download-client-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--accent);
}
.download-client-option-label {
flex: 1;
font-size: 0.85rem;
color: var(--text-primary);
cursor: pointer;
}
.download-client-type {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--surface-alt);
padding: 1px 6px;
border-radius: 3px;
}
/* Empty state */
.download-client-empty {
padding: 12px;
text-align: center;
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Client icon */
.download-client-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
}
.download-client-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.download-client-icon.fallback {
font-size: 14px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-alt);
border-radius: 3px;
color: var(--text-primary);
}
.history-header {
display: flex;
align-items: center;
@@ -1193,7 +1403,6 @@ body {
text-transform: capitalize;
background: var(--accent-light);
color: var(--accent);
margin-left: auto;
white-space: nowrap;
}
@@ -1203,6 +1412,52 @@ body {
margin-left: 0;
}
/* Download client logo in card */
.download-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
margin-left: auto;
}
.download-client-logo-wrapper {
width: 20px;
height: 20px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Card-specific logo wrapper positioned at bottom right */
.download-card-logo-wrapper {
width: 32px;
height: 32px;
position: absolute;
bottom: 8px;
right: 8px;
}
.download-client-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.download-client-logo-wrapper.fallback {
font-size: 10px;
font-weight: bold;
background: var(--surface-alt);
border-radius: 2px;
color: var(--text-primary);
}
.download-card-logo-wrapper.fallback {
font-size: 20px;
border-radius: 4px;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
@@ -1232,6 +1487,22 @@ body {
box-shadow: 0 2px 4px var(--shadow);
}
#status-content {
min-height: 150px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--background);
padding: 10px;
margin-bottom: 16px;
}
.status-loading {
color: var(--text-muted);
font-style: italic;
text-align: center;
padding: 20px;
}
.status-header {
display: flex;
justify-content: space-between;
@@ -1516,3 +1787,219 @@ body {
font-size: 0.7rem;
}
}
/* ===== Webhooks Configuration ===== */
.webhooks-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--shadow);
overflow: hidden;
}
.webhooks-header {
padding: 16px 20px;
background: var(--surface-alt);
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.webhooks-header:hover {
background: var(--border);
}
.webhooks-header h2 {
color: var(--text-primary);
font-size: 1.1rem;
margin: 0;
font-weight: 600;
}
/* Webhooks metrics styling to match status cards */
.webhook-stats {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-top: 12px;
}
.webhooks-toggle {
font-size: 1rem;
color: var(--text-muted);
transition: transform 0.3s;
}
.webhooks-toggle.expanded {
transform: rotate(180deg);
}
.webhooks-content {
padding: 20px;
}
.webhook-loading {
text-align: center;
padding: 20px;
color: var(--text-muted);
}
.webhook-instance {
padding: 20px 0;
border-bottom: 1px solid var(--border);
}
.webhook-instance:last-child {
border-bottom: none;
padding-bottom: 0;
}
.webhook-instance h3 {
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 12px;
font-weight: 600;
}
.webhook-status {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.status-indicator {
font-size: 0.95rem;
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background: var(--surface-alt);
color: var(--text-muted);
}
.status-indicator.enabled {
background: var(--success-bg);
color: var(--success);
}
.status-indicator.disabled {
background: var(--surface-alt);
color: var(--text-muted);
}
.enable-webhook-btn {
padding: 6px 14px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.2s;
}
.enable-webhook-btn:hover {
background: var(--accent-hover);
}
.enable-webhook-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.test-webhook-btn {
padding: 6px 14px;
background: var(--info);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.2s;
}
.test-webhook-btn:hover {
background: var(--info-hover, var(--info));
filter: brightness(0.9);
}
.test-webhook-btn:disabled {
background: var(--text-muted);
cursor: not-allowed;
}
.webhook-triggers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
padding: 12px;
background: var(--surface-alt);
border-radius: 8px;
margin-top: 8px;
}
.trigger-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
}
.trigger-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.trigger-value {
font-weight: 600;
font-size: 1rem;
}
.trigger-value.active {
color: var(--success);
}
.trigger-value.inactive {
color: var(--text-muted);
}
.webhook-stats-title {
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.webhook-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
}
.webhook-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.webhook-stat-label {
color: var(--text-muted);
font-size: 0.75rem;
}
.webhook-stat-value {
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
}
+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,
+17 -8
View File
@@ -45,9 +45,10 @@ class SABnzbdClient extends DownloadClient {
async getActiveDownloads() {
try {
// Get both queue and history to provide complete picture
const [queueResponse, historyResponse] = await Promise.all([
const [queueResponse, historyResponse, clientStatus] = await Promise.all([
this.makeRequest({ mode: 'queue' }),
this.makeRequest({ mode: 'history', limit: 10 })
this.makeRequest({ mode: 'history', limit: 10 }),
this.getClientStatus()
]);
const queueData = queueResponse.data;
@@ -57,15 +58,23 @@ class SABnzbdClient extends DownloadClient {
// Process active queue items
if (queueData.queue && queueData.queue.slots) {
// Find the currently downloading slot (first one with status 'Downloading')
const activeSlot = queueData.queue.slots.find(slot => slot.status === 'Downloading');
const activeSpeed = activeSlot && clientStatus ? (clientStatus.kbpersec ? clientStatus.kbpersec * 1024 : 0) : 0;
logToFile(`[SABnzbd:${this.name}] Active slot: ${activeSlot ? activeSlot.nzo_id : 'none'}, Speed: ${activeSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
for (const slot of queueData.queue.slots) {
downloads.push(this.normalizeDownload(slot, 'queue'));
const slotSpeed = activeSlot === slot ? activeSpeed : 0;
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
}
}
// Process recent history items (last 10)
if (historyData.history && historyData.history.slots) {
for (const slot of historyData.history.slots) {
downloads.push(this.normalizeDownload(slot, 'history'));
downloads.push(this.normalizeDownload(slot, 'history', 0));
}
}
@@ -102,9 +111,9 @@ class SABnzbdClient extends DownloadClient {
}
}
normalizeDownload(slot, source) {
normalizeDownload(slot, source, speed = 0) {
const isHistory = source === 'history';
// Map SABnzbd statuses to normalized status
const statusMap = {
'Downloading': 'Downloading',
@@ -164,10 +173,10 @@ class SABnzbdClient extends DownloadClient {
progress: Math.round(progress),
size: Math.round(size),
downloaded: Math.round(downloaded),
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
speed: speed,
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,
+198 -15
View File
@@ -8,6 +8,7 @@ const { mapTorrentToDownload } = require('../utils/qbittorrent');
const cache = require('../utils/cache');
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const downloadClientRegistry = require('../utils/downloadClients');
const sanitizeError = require('../utils/sanitizeError');
@@ -772,7 +773,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 +783,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,13 +860,28 @@ 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 });
}
});
// Webhook metrics — exposes global and per-instance webhook metrics for the
// Webhooks Configuration panel. Available to all authenticated users.
router.get('/webhook-metrics', requireAuth, (req, res) => {
try {
const { getGlobalWebhookMetrics } = require('../utils/cache');
res.json(getGlobalWebhookMetrics());
} catch (err) {
res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message });
}
});
// Cover art proxy — fetches external poster images server-side so the
// browser loads them from 'self' and the CSP img-src stays tight.
// Requires authentication. Only proxies http/https URLs.
@@ -868,6 +947,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') || [];
@@ -878,6 +959,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 };
@@ -919,24 +1004,113 @@ 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);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
@@ -946,17 +1120,15 @@ 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);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
@@ -985,7 +1157,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
userDownloads.push(dlObj);
}
@@ -1002,7 +1174,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
userDownloads.push(dlObj);
}
@@ -1083,7 +1255,18 @@ router.get('/stream', requireAuth, async (req, res) => {
}
// Write SSE event
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
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(', ')}`);
}
// Get download clients list for ordering/filtering
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(),
name: c.name,
type: c.getClientType()
}));
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\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}`);
}
+7 -3
View File
@@ -178,10 +178,12 @@ async function pollAllServices() {
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
raw: d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};
const sabHistoryLegacy = {
slots: sabHistory.map(d => ({
nzo_id: d.id,
@@ -191,7 +193,9 @@ async function pollAllServices() {
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
raw: d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};
+2
View File
@@ -102,6 +102,8 @@ function mapTorrentToDownload(torrent) {
return {
type: 'torrent',
title: torrent.name,
client: 'qbittorrent',
instanceId: torrent.instanceId,
instanceName: torrent.instanceName,
status: status,
progress: progress.toFixed(1),