Compare commits
75 Commits
v1.5.1
...
release/1.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f10414498 | |||
| 1e3926b206 | |||
| 5fde69fcf5 | |||
| a562cfe9aa | |||
| 8549746721 | |||
| 63fc370262 | |||
| 6362441dd5 | |||
| 76f9e87b44 | |||
| 8c461de72a | |||
| d11f11be69 | |||
| 05d11975e6 | |||
| cd3480c0ce | |||
| 712c98d817 | |||
| ff7ace9f4f | |||
| 73500751a0 | |||
| 82a9df134b | |||
| 67fa79796b | |||
| f06d945358 | |||
| f5883d4929 | |||
| 80cf3eaa39 | |||
| 1ab7e52167 | |||
| 544c168b82 | |||
| 747a14ebd3 | |||
| 49d66c07ee | |||
| be791ed044 | |||
| 7195a09562 | |||
| 720de6688b | |||
| 3e06bdf8cd | |||
| ca1c136d4f | |||
| a04f2c9b25 | |||
| 743b169989 | |||
| 794cb7268e | |||
| d310d101ed | |||
| 96f24eb3b7 | |||
| abcb9bfded | |||
| e5920b207f | |||
| d3483f3be7 | |||
| 252cc50aa4 | |||
| 57908e2b9e | |||
| e2757768c7 | |||
| 2469c3e3f4 | |||
| 6c8c333c6a | |||
| 5dfe0b1216 | |||
| 77beef787f | |||
| 235a866ec8 | |||
| f1d9de2a92 | |||
| 9d0e31ec9a | |||
| 42c3eebf18 | |||
| f295e1c90d | |||
| c5e8281440 | |||
| f22dd0d1f6 | |||
| 5159a83475 | |||
| ccc3b6ffec | |||
| 4ec7d734b8 | |||
| 2e85fae57a | |||
| aeacadbe68 | |||
| 3ef35a8c43 | |||
| 0f3c02e52d | |||
| 9fd60bcfed | |||
| af58e1bf2a | |||
| 2d04402284 | |||
| 0310f10e5d | |||
| 5ab8cc96a3 | |||
| a7363fcb3a | |||
| d06e24dbb6 | |||
| 6df94e5ad2 | |||
| 015e07ae7a | |||
| 740b03ac85 | |||
| 575688dab7 | |||
| 84658102e0 | |||
| ae9e877445 | |||
| 853b205c46 | |||
| 65b9f0f395 | |||
| 20dfe06866 | |||
| 3bb9e936c3 |
+43
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user