diff --git a/.gitignore b/.gitignore index 47ec192..3401af4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ data/ *.db *.db-wal *.db-shm +.agents/ +.windsurf/ \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6558ab6..e345c6e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,6 +51,7 @@ flowchart TB dash["Dashboard Cards"] status["Status Panel\n(Admin only)"] history["History Tab"] + requests["Requests Tab\n+ Filters / Search"] webhooks["Webhook Config"] swagger["Swagger UI\n/api/swagger"] end @@ -61,7 +62,8 @@ flowchart TB auth_r["Auth Routes\n/api/auth"] dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"] stat_r["Status Routes\n/api/status"] - wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"] + wh_r["Webhook Routes\n/api/webhook/sonarr|radarr|ombi"] + ombi_r["Ombi Routes\n/api/ombi"] hist_r["History Routes\n/api/history"] proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"] @@ -82,12 +84,14 @@ flowchart TB rtorrent["rTorrent"] transmission["Transmission"] emby["Emby / Jellyfin"] + ombi["Ombi"] end login -->|"POST /api/auth/login"| auth_r dash -->|"GET /api/dashboard/stream (SSE)"| dash_r status -->|"GET /api/status"| stat_r history -->|"GET /api/history/recent"| hist_r + requests -->|"GET /api/ombi/requests"| ombi_r auth_r --> tokenstore auth_r -->|"authenticate"| emby @@ -97,13 +101,14 @@ flowchart TB stat_r --> cache wh_r --> cache wh_r --> paldra + ombi_r --> paldra hist_r --> cache proxy_r -->|"proxy"| sonarr & radarr & sab & emby poller --> pdca & paldra poller --> cache pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission - paldra -->|"HTTP/API"| sonarr & radarr + paldra -->|"HTTP/API"| sonarr & radarr & ombi sonarr & radarr -->|"POST /api/webhook/*"| wh_r ``` @@ -114,7 +119,7 @@ flowchart TB Browser (SPA) │ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie │ GET /api/dashboard/stream → SSE stream → cache → matched downloads - │ POST /api/webhook/* ← Sonarr/Radarr push events + │ POST /api/webhook/* ← Sonarr/Radarr/Ombi push events │ ▼ Express Server (:3001) @@ -127,9 +132,12 @@ Express Server (:3001) ├── /api/auth → login, logout, me, csrf ├── /api/webhook → [rate-limit] → [secret validation] → [payload validation] │ → [replay check] → updateWebhookMetrics → processWebhookEvent + │ → /config: GET endpoint for configuration status validation ├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON ├── /api/status → requireAuth → admin cache/polling/webhook status ├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup + ├── /api/ombi → requireAuth → PALDRA → filter/sort/search → JSON + │ → /webhook/*: enable (POST), status (GET), and test (POST) endpoints ├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API └── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy @@ -300,6 +308,17 @@ arrRetrieverRegistry = { } ``` +#### Ombi retriever + +The `OmbiRetriever` (in `server/clients/OmbiClient.js`) fetches from: + +| Task | Endpoint | Notes | +|------|----------|-------| +| Movie requests | `GET /api/v1/Request/movie` | Returns full movie request objects | +| TV requests | `GET /api/v1/Request/tv` | Returns full TV request objects | + +Results are cached under `poll:ombi` and broadcast via SSE as `ombiRequests: { movie, tv }`. The client applies the same `ombiFilters.js` logic used by the server route, keeping behaviour consistent across both layers. + Each result element is `{ instance: instanceId, data: }`, allowing callers to look up instance credentials from `config.js`. #### Retriever API Calls @@ -363,11 +382,12 @@ Unmatched torrents are **not** included in the response (fixed in develop-refact ### 4.1 Webhook Receiver -sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event: +sofarr exposes three webhook endpoints that Sonarr, Radarr, and Ombi can be configured to call on automation and request events: ``` POST /api/webhook/sonarr POST /api/webhook/radarr +POST /api/webhook/ombi ``` Both endpoints share identical processing logic: @@ -451,6 +471,8 @@ The dashboard therefore receives fresh data within the round-trip time of the *a The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL. +Similarly, the `ombi.js` route module exposes endpoints under `/api/ombi/webhook/` (including `/enable`, `/status`, and `/test`) to support one-click registration and validation of the Sofarr webhook inside the configured Ombi instance. + --- ## 5. Data Flow and Real-time Updates @@ -539,7 +561,12 @@ The browser's native `EventSource` API handles reconnection automatically on net id: string, // Instance identifier name: string, // Instance display name type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent') - }[] + }[], + ombiRequests: { // Raw Ombi movie + TV requests (client applies filters) + movie: OmbiRequest[], + tv: OmbiRequest[] + }, + ombiBaseUrl: string // Ombi instance base URL for deep links } ``` @@ -631,6 +658,67 @@ Matched download objects include `client`, `instanceId`, and `instanceName` fiel | `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added | | `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk | +### 5.5 Ombi Request Filtering + +The Ombi Requests tab displays movie and TV requests from Ombi. Filtering, sorting, and text search are applied **server-side** on the REST endpoint (`GET /api/ombi/requests`) and **client-side** on every SSE update. This dual-layer approach ensures external API consumers receive pre-filtered data while the SPA remains responsive without extra round-trips. + +```mermaid +sequenceDiagram + participant Client as Browser (Requests Tab) + participant SSE as SSE /api/dashboard/stream + participant Route as /api/ombi/requests + participant Filters as ombiFilters (shared) + participant PALDRA as PALDRA Registry + participant Ombi as Ombi API + + Note over Client: Initial load + Client->>Route: GET /api/ombi/requests?type=…&status=…&sort=…&search=… + Route->>PALDRA: getOmbiRequests() + PALDRA->>Ombi: GET /api/v1/Request/movie + /tv + Ombi-->>PALDRA: raw request arrays + PALDRA-->>Route: { movie: [], tv: [] } + Route->>Filters: applyRequestFilters() + Filters-->>Route: filtered & sorted requests + Route-->>Client: { requests: { movie, tv }, total } + + Note over Client: Real-time updates + SSE->>Client: push raw ombiRequests + ombiBaseUrl + Client->>Filters: applyRequestFilters() (same code) + Filters-->>Client: filtered & sorted requests + Client->>Client: renderRequests() +``` + +#### Query parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `type` | `movie` \| `tv` \| `all` | `all` | Media type filter (multi-select) | +| `status` | `pending` \| `approved` \| `available` \| `denied` | — | Request status filter (multi-select) | +| `sort` | `requestedDate_desc` \| `requestedDate_asc` \| `title_asc` \| `title_desc` | `requestedDate_desc` | Sort mode | +| `search` | string | — | Case-insensitive title substring | +| `showAll` | `'true'` \| `'false'` | `'false'` | Admin only: show all users' requests | + +#### Status priority + +The same `getRequestStatus()` function runs on both server and client: + +1. `available` — if `available === true` +2. `denied` — if `denied === true` +3. `approved` — if `approved === true` +4. `pending` — if `requested === true` +5. `unknown` — fallback + +#### Persistence + +Filter and sort preferences are persisted in `localStorage` under the following keys: + +| Key | Content | +|-----|---------| +| `sofarr-request-types` | `['movie', 'tv']` or subset | +| `sofarr-request-statuses` | `['pending', 'approved', 'available', 'denied']` or subset | +| `sofarr-request-sort` | `requestedDate_desc`, `requestedDate_asc`, `title_asc`, `title_desc` | +| `sofarr-request-search` | Free-text query string | + --- ## 6. Caching and Smart Polling @@ -667,6 +755,7 @@ class MemoryCache { | `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` | | `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` | | `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` | +| `poll:ombi` | `{ movie: [], tv: [] }` | `POLL_INTERVAL × 3` | | `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min | | `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min | | `emby:users` | `Map` | 60 s | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db03de..d247b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.7.3] - 2026-05-23 + +### Fixed + +- **Download client dropdown filter icons and type badges** — Refactored the download client selector dropdown in `client/src/ui/filters.js` to render utilizing the design system classes `.download-client-option`, `.download-client-icon`, `.download-client-option-label`, and `.download-client-type`. Options now display the client's brand SVG logo (e.g., `sabnzbd.svg`, `qbittorrent.svg` loaded from `/images/clients/`) next to the configured display name, along with a clean pill badge indicating the client type. This resolves visual ambiguity when multiple download clients share the same name (such as `"i3omb"` for SABnzbd and qBittorrent). + +--- + +## [1.7.2] - 2026-05-22 + +### Fixed + +- **Webhook configuration check** — Ombi webhook status now correctly checks for `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` environment variables. The webhook displays as disabled when either is missing, matching the existing *ARR webhook behavior. +- **Common webhook config endpoint** — Added `GET /api/webhook/config` endpoint that returns whether both `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` are configured, along with a list of missing items. Used by the client to determine webhook enablement status across all webhook types. +- **Client-side webhook status** — Updated `fetchWebhookStatus()` to call `/api/webhook/config` and use the result to determine if Sonarr and Radarr webhooks are enabled. Webhook status now depends on both the service-specific webhook being configured AND the Sofarr webhook config being valid. +- **Webhook config endpoint authentication** — Added `requireAuth` middleware to `GET /api/webhook/config` to enforce authentication, matching the OpenAPI contract and preventing unauthenticated information disclosure. +- **Ombi webhook enable/test config validation** — `POST /api/ombi/webhook/enable` and `POST /api/ombi/webhook/test` now validate `SOFARR_BASE_URL` and `SOFARR_WEBHOOK_SECRET` before making outbound Ombi API calls, returning `400` with descriptive errors when either is missing. Previously these routes would construct malformed URLs (`undefined/api/webhook/ombi`) if the environment variables were unset. +- **Test environment cleanup** — `tests/integration/webhook.test.js` `afterEach` now cleans up `EMBY_URL`, `SOFARR_BASE_URL`, `SONARR_INSTANCES`, and `RADARR_INSTANCES` to ensure hermetic test isolation. + +--- + ## [1.8.0] - 2026-05-21 ### Added diff --git a/README.md b/README.md index c15cc42..c464630 100644 --- a/README.md +++ b/README.md @@ -320,17 +320,16 @@ OMBI_URL=https://ombi.example.com OMBI_API_KEY=your-ombi-api-key ``` -**How it works:** -- Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB) -- When a matching request is found, an Ombi icon appears in the download card -- Clicking the icon opens the Ombi request page -- If no request exists, a search link is provided instead -- Integration is fully optional - sofarr works perfectly without Ombi configured - -**External ID Matching:** -- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback) -- **Movies**: TMDB ID (primary) → IMDB ID (fallback) -- Matching is performed automatically using data from Sonarr/Radarr +**Features & Architecture:** +- **Request Filtering & Search**: Retrieve, search, and filter requests in real-time by status (pending, approved, available, denied), media type (movies, TV shows), or name. +- **Dual-Layer Filtering & Persistence**: search and core filters are handled server-side for speed and efficiency, while responsive client-side refinements handle on-the-fly rendering. User filter preferences are persisted locally/session-wide to ensure a consistent experience across page refreshes. +- **Real-Time SSE Updates**: Integrates with the Server-Sent Events (SSE) push stream. When requests are created or updated, the dashboard is instantly notified without client-side polling. +- **External ID Matching**: Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB). + - **TV Shows**: TVDB ID (primary) → TMDB ID (fallback) + - **Movies**: TMDB ID (primary) → IMDB ID (fallback) + - Matching is performed automatically using data from Sonarr/Radarr. +- **Interactive UI**: When a matching request is found, an Ombi icon appears in the download card to open the request page. If no request exists, a search link is provided instead. +- **Fully Optional**: Integration is fully optional — sofarr works perfectly without Ombi configured. ## Setting Up User Tags @@ -445,8 +444,10 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e ### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`) - `POST /api/webhook/sonarr` — receive Sonarr webhook events - `POST /api/webhook/radarr` — receive Radarr webhook events +- `POST /api/webhook/ombi` — receive Ombi webhook events ### Webhook Management (requires auth + CSRF) +- `GET /api/webhook/config` — get webhook configuration status - `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections - `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection - `GET /api/radarr/api/v3/notification` — list Radarr notification connections @@ -455,6 +456,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e - `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr - `POST /api/sonarr/webhook/test` — trigger a Sonarr test event - `POST /api/radarr/webhook/test` — trigger a Radarr test event +- `GET /api/ombi/webhook/status` — get Ombi webhook status and metrics +- `POST /api/ombi/webhook/enable` — one-click enable Sofarr webhook in Ombi +- `POST /api/ombi/webhook/test` — trigger an Ombi test event + +### Ombi (requires auth) +- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting ### Service APIs (proxy to your services) - `GET /api/sabnzbd/*` — SABnzbd API proxy @@ -499,7 +506,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/) npm run test:ui # interactive Vitest UI ``` -290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. +Over 830 tests across 39 files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, webhook metrics integration, polling retrievers, and Ombi filter/search logic. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. ## Development diff --git a/client/src/api.js b/client/src/api.js index 598017d..22a0e22 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -137,7 +137,19 @@ export async function fetchWebhookStatus() { try { // Fetch metrics in parallel const metricsPromise = fetchWebhookMetrics(); - + + // Fetch webhook configuration status (checks SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET) + let webhookConfigValid = false; + try { + const configRes = await fetch('/api/webhook/config'); + if (configRes.ok) { + const configData = await configRes.json(); + webhookConfigValid = configData.valid || false; + } + } catch (err) { + // Config endpoint not available, assume invalid + } + // Fetch Sonarr notifications let sonarrEnabled = false; let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; @@ -146,7 +158,7 @@ export async function fetchWebhookStatus() { if (sonarrRes.ok) { const sonarrData = await sonarrRes.json(); const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr'); - sonarrEnabled = !!sonarrSofarr; + sonarrEnabled = webhookConfigValid && !!sonarrSofarr; if (sonarrSofarr) { sonarrTriggers = { onGrab: sonarrSofarr.onGrab, @@ -159,7 +171,7 @@ export async function fetchWebhookStatus() { } catch (err) { // Sonarr not configured } - + // Fetch Radarr notifications let radarrEnabled = false; let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; @@ -168,7 +180,7 @@ export async function fetchWebhookStatus() { if (radarrRes.ok) { const radarrData = await radarrRes.json(); const radarrSofarr = radarrData.find(n => n.name === 'Sofarr'); - radarrEnabled = !!radarrSofarr; + radarrEnabled = webhookConfigValid && !!radarrSofarr; if (radarrSofarr) { radarrTriggers = { onGrab: radarrSofarr.onGrab, @@ -181,6 +193,22 @@ export async function fetchWebhookStatus() { } catch (err) { // Radarr not configured } + + // Fetch Ombi webhook status + let ombiEnabled = false; + let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }; + let ombiStats = null; + try { + const ombiRes = await fetch('/api/ombi/webhook/status'); + if (ombiRes.ok) { + const ombiData = await ombiRes.json(); + ombiEnabled = ombiData.enabled || false; + ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }; + ombiStats = ombiData.stats || null; + } + } catch (err) { + // Ombi not configured + } state.webhookMetrics = await metricsPromise; @@ -191,6 +219,7 @@ export async function fetchWebhookStatus() { state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }; state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }; + state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats }; return { success: true }; } catch (err) { @@ -279,6 +308,36 @@ export async function testRadarrWebhook() { } } +export async function enableOmbiWebhook() { + try { + const res = await fetch('/api/ombi/webhook/enable', { + method: 'POST', + headers: { 'X-CSRF-Token': state.csrfToken || '' } + }); + if (!res.ok) throw new Error('Failed to enable'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to enable Ombi webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function testOmbiWebhook() { + try { + const res = await fetch('/api/ombi/webhook/test', { + method: 'POST', + headers: { 'X-CSRF-Token': state.csrfToken || '' } + }); + if (!res.ok) throw new Error('Test failed'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to test Ombi webhook:', err); + return { success: false, error: err.message }; + } +} + export async function refreshStatusPanel() { try { const res = await fetch('/api/status'); diff --git a/client/src/main.js b/client/src/main.js index 076345d..468f505 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -3,6 +3,7 @@ // Bootstrap - wire all event handlers on DOMContentLoaded import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js'; import { initDownloadClientFilter } from './ui/filters.js'; +import { initRequestFilters } from './ui/requestFilters.js'; import { initHistoryControls } from './ui/history.js'; import { toggleStatusPanel } from './ui/statusPanel.js'; import { initWebhooks } from './ui/webhooks.js'; @@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => { initThemeSwitcher(); initTabs(); initDownloadClientFilter(); + initRequestFilters(); initHistoryControls(); initWebhooks(); diff --git a/client/src/sse.js b/client/src/sse.js index 25b24b0..5e635bd 100644 --- a/client/src/sse.js +++ b/client/src/sse.js @@ -25,6 +25,16 @@ export function startSSE() { const filterUpdateEvent = new CustomEvent('downloadClientsUpdated'); document.dispatchEvent(filterUpdateEvent); } + // Store Ombi requests and base URL + if (data.ombiRequests) { + state.ombiRequests = data.ombiRequests; + // Trigger requests update event + const requestsUpdateEvent = new CustomEvent('ombiRequestsUpdated'); + document.dispatchEvent(requestsUpdateEvent); + } + if (data.ombiBaseUrl) { + state.ombiBaseUrl = data.ombiBaseUrl; + } document.getElementById('currentUser').textContent = state.currentUser || '-'; renderDownloads(); hideError(); diff --git a/client/src/state.js b/client/src/state.js index a3a1d5f..b4491dc 100644 --- a/client/src/state.js +++ b/client/src/state.js @@ -9,6 +9,8 @@ export const state = { isAdmin: false, showAll: false, csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests + ombiBaseUrl: null, // Ombi base URL for generating links + ombiRequests: null, // Ombi requests data // History section state historyDays: 7, // Default value, will be loaded from localStorage @@ -28,7 +30,14 @@ export const state = { webhookLoading: false, sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }, radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }, - webhookMetrics: null + ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null }, + webhookMetrics: null, + + // Request filter state + selectedRequestTypes: ['movie', 'tv'], + selectedRequestStatuses: [], + requestSortMode: 'requestedDate_desc', + requestSearchQuery: '' }; // Constants diff --git a/client/src/ui/filters.js b/client/src/ui/filters.js index 08958d1..a8f56b9 100644 --- a/client/src/ui/filters.js +++ b/client/src/ui/filters.js @@ -5,9 +5,10 @@ import { saveDownloadClients } from '../utils/storage.js'; import { renderDownloads } from './downloads.js'; export function initDownloadClientFilter() { - const filterBtn = document.getElementById('download-client-filter-btn'); - const filterDropdown = document.getElementById('download-client-filter-dropdown'); - const filterClose = document.getElementById('download-client-filter-close'); + const filterBtn = document.getElementById('download-client-dropdown-btn'); + const filterDropdown = document.getElementById('download-client-dropdown'); + const selectAllBtn = document.getElementById('download-client-select-all'); + const deselectAllBtn = document.getElementById('download-client-deselect-all'); if (!filterBtn || !filterDropdown) return; @@ -16,13 +17,16 @@ export function initDownloadClientFilter() { filterDropdown.classList.toggle('open'); }); - filterClose.addEventListener('click', () => { - filterDropdown.classList.remove('open'); - }); + if (selectAllBtn) { + selectAllBtn.addEventListener('click', () => toggleAllClients(true)); + } + if (deselectAllBtn) { + deselectAllBtn.addEventListener('click', () => toggleAllClients(false)); + } // Close dropdown when clicking outside document.addEventListener('click', (e) => { - if (!filterDropdown.contains(e.target) && e.target !== filterBtn) { + if (!filterDropdown.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { filterDropdown.classList.remove('open'); } }); @@ -35,28 +39,47 @@ export function initDownloadClientFilter() { } export function updateDownloadClientFilter() { - const filterList = document.getElementById('download-client-filter-list'); + const filterList = document.getElementById('download-client-options'); if (!filterList) return; filterList.innerHTML = ''; state.downloadClients.forEach((client, index) => { const item = document.createElement('div'); - item.className = 'filter-item'; + item.className = 'download-client-option'; item.dataset.index = index; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.className = 'download-client-checkbox'; checkbox.id = `client-${index}`; checkbox.checked = state.selectedDownloadClients.includes(index); checkbox.addEventListener('change', () => toggleClientSelection(index)); + 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 || client.type} icon`; + iconImg.onerror = () => { + 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 = `client-${index}`; label.textContent = client.name || `${client.type} (${client.id})`; + const typeBadge = document.createElement('span'); + typeBadge.className = 'download-client-type'; + typeBadge.textContent = client.type; + item.appendChild(checkbox); + item.appendChild(iconWrapper); item.appendChild(label); + item.appendChild(typeBadge); filterList.appendChild(item); }); @@ -75,13 +98,33 @@ export function toggleClientSelection(index) { renderDownloads(); } +export function toggleAllClients(select) { + if (select) { + state.selectedDownloadClients = state.downloadClients.map((_, index) => index); + } else { + state.selectedDownloadClients = []; + } + saveDownloadClients(state.selectedDownloadClients); + updateDownloadClientFilter(); + renderDownloads(); +} + export function updateSelectedCountDisplay() { - const countDisplay = document.getElementById('download-client-filter-count'); + const countDisplay = document.getElementById('download-client-selected-text'); if (!countDisplay) return; if (state.selectedDownloadClients.length === 0) { - countDisplay.textContent = 'All'; + countDisplay.textContent = 'All clients'; + } else if (state.selectedDownloadClients.length === state.downloadClients.length) { + countDisplay.textContent = 'All clients'; } else { - countDisplay.textContent = state.selectedDownloadClients.length; + const names = state.selectedDownloadClients + .map(idx => state.downloadClients[idx]?.name || state.downloadClients[idx]?.type || '') + .filter(Boolean); + if (names.length === 1) { + countDisplay.textContent = names[0]; + } else { + countDisplay.textContent = `${state.selectedDownloadClients.length} clients`; + } } } diff --git a/client/src/ui/requestFilters.js b/client/src/ui/requestFilters.js new file mode 100644 index 0000000..079b858 --- /dev/null +++ b/client/src/ui/requestFilters.js @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; +import { + saveRequestTypes, + saveRequestStatuses, + saveRequestSort, + saveRequestSearch +} from '../utils/storage.js'; +import { renderRequests } from './requests.js'; + +// ---- Type filter dropdown ---- + +function initTypeFilter() { + const btn = document.getElementById('request-type-filter-btn'); + const dropdown = document.getElementById('request-type-filter-dropdown'); + const selectAll = document.getElementById('request-type-select-all'); + const deselectAll = document.getElementById('request-type-deselect-all'); + + if (!btn || !dropdown) return; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.classList.toggle('open'); + }); + + selectAll?.addEventListener('click', () => setAllTypes(true)); + deselectAll?.addEventListener('click', () => setAllTypes(false)); + + // Wire up checkboxes + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + checkboxes.forEach(cb => { + cb.addEventListener('change', () => { + const value = cb.closest('.request-filter-option').dataset.value; + toggleType(value, cb.checked); + }); + }); + + updateTypeFilterUI(); +} + +function setAllTypes(checked) { + const dropdown = document.getElementById('request-type-filter-dropdown'); + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + const newTypes = []; + checkboxes.forEach(cb => { + cb.checked = checked; + if (checked) newTypes.push(cb.closest('.request-filter-option').dataset.value); + }); + state.selectedRequestTypes = checked ? newTypes : []; + saveRequestTypes(state.selectedRequestTypes); + updateTypeFilterUI(); + renderRequests(); +} + +function toggleType(value, checked) { + const idx = state.selectedRequestTypes.indexOf(value); + if (checked && idx === -1) { + state.selectedRequestTypes.push(value); + } else if (!checked && idx > -1) { + state.selectedRequestTypes.splice(idx, 1); + } + saveRequestTypes(state.selectedRequestTypes); + updateTypeFilterUI(); + renderRequests(); +} + +function updateTypeFilterUI() { + const text = document.getElementById('request-type-selected-text'); + if (!text) return; + + const dropdown = document.getElementById('request-type-filter-dropdown'); + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + checkboxes.forEach(cb => { + const value = cb.closest('.request-filter-option').dataset.value; + cb.checked = state.selectedRequestTypes.includes(value); + }); + + if (state.selectedRequestTypes.length === 0) { + text.textContent = 'All'; + } else if (state.selectedRequestTypes.length === checkboxes.length) { + text.textContent = 'All'; + } else { + text.textContent = state.selectedRequestTypes.length; + } +} + +// ---- Status filter dropdown ---- + +function initStatusFilter() { + const btn = document.getElementById('request-status-filter-btn'); + const dropdown = document.getElementById('request-status-filter-dropdown'); + const selectAll = document.getElementById('request-status-select-all'); + const deselectAll = document.getElementById('request-status-deselect-all'); + + if (!btn || !dropdown) return; + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.classList.toggle('open'); + }); + + selectAll?.addEventListener('click', () => setAllStatuses(true)); + deselectAll?.addEventListener('click', () => setAllStatuses(false)); + + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + checkboxes.forEach(cb => { + cb.addEventListener('change', () => { + const value = cb.closest('.request-filter-option').dataset.value; + toggleStatus(value, cb.checked); + }); + }); + + updateStatusFilterUI(); +} + +function setAllStatuses(checked) { + const dropdown = document.getElementById('request-status-filter-dropdown'); + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + const newStatuses = []; + checkboxes.forEach(cb => { + cb.checked = checked; + if (checked) newStatuses.push(cb.closest('.request-filter-option').dataset.value); + }); + state.selectedRequestStatuses = checked ? newStatuses : []; + saveRequestStatuses(state.selectedRequestStatuses); + updateStatusFilterUI(); + renderRequests(); +} + +function toggleStatus(value, checked) { + const idx = state.selectedRequestStatuses.indexOf(value); + if (checked && idx === -1) { + state.selectedRequestStatuses.push(value); + } else if (!checked && idx > -1) { + state.selectedRequestStatuses.splice(idx, 1); + } + saveRequestStatuses(state.selectedRequestStatuses); + updateStatusFilterUI(); + renderRequests(); +} + +function updateStatusFilterUI() { + const text = document.getElementById('request-status-selected-text'); + if (!text) return; + + const dropdown = document.getElementById('request-status-filter-dropdown'); + const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox'); + checkboxes.forEach(cb => { + const value = cb.closest('.request-filter-option').dataset.value; + cb.checked = state.selectedRequestStatuses.includes(value); + }); + + if (state.selectedRequestStatuses.length === 0) { + text.textContent = 'All'; + } else if (state.selectedRequestStatuses.length === checkboxes.length) { + text.textContent = 'All'; + } else { + text.textContent = state.selectedRequestStatuses.length; + } +} + +// ---- Sort select ---- + +function initSortSelect() { + const select = document.getElementById('request-sort-select'); + if (!select) return; + + select.value = state.requestSortMode; + select.addEventListener('change', (e) => { + state.requestSortMode = e.target.value; + saveRequestSort(state.requestSortMode); + renderRequests(); + }); +} + +// ---- Search input ---- + +function initSearchInput() { + const input = document.getElementById('request-search-input'); + if (!input) return; + + input.value = state.requestSearchQuery; + + let debounceTimer; + input.addEventListener('input', (e) => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + state.requestSearchQuery = e.target.value; + saveRequestSearch(state.requestSearchQuery); + renderRequests(); + }, 200); + }); +} + +// ---- Global click-outside handler ---- + +function initClickOutside() { + document.addEventListener('click', (e) => { + const typeDropdown = document.getElementById('request-type-filter-dropdown'); + const typeBtn = document.getElementById('request-type-filter-btn'); + const statusDropdown = document.getElementById('request-status-filter-dropdown'); + const statusBtn = document.getElementById('request-status-filter-btn'); + + if (typeDropdown && !typeDropdown.contains(e.target) && e.target !== typeBtn && !typeBtn?.contains(e.target)) { + typeDropdown.classList.remove('open'); + } + if (statusDropdown && !statusDropdown.contains(e.target) && e.target !== statusBtn && !statusBtn?.contains(e.target)) { + statusDropdown.classList.remove('open'); + } + }); +} + +// ---- Public API ---- + +export function initRequestFilters() { + initTypeFilter(); + initStatusFilter(); + initSortSelect(); + initSearchInput(); + initClickOutside(); + + // Listen for SSE updates (registered once on app bootstrap) + document.addEventListener('ombiRequestsUpdated', () => { + renderRequests(); + }); +} diff --git a/client/src/ui/requests.js b/client/src/ui/requests.js new file mode 100644 index 0000000..c3a0e1d --- /dev/null +++ b/client/src/ui/requests.js @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; +import { escapeHtml } from '../utils/format.js'; +import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js'; + +/** + * Helper function to extract the username from an Ombi request object. + * The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object, + * not a string, so we need to extract the username from the object. + * + * Must stay in sync with server/utils/ombiHelpers.js + * + * @param {Object} request - The Ombi request object + * @returns {string} The extracted username, or empty string if not found + */ +function extractRequestedUser(request) { + if (!request) return ''; + + // Handle object format: OmbiStore.Entities.OmbiUser + if (request.requestedUser && typeof request.requestedUser === 'object') { + // Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias + return request.requestedUser.alias || + request.requestedUser.userAlias || + request.requestedUser.userName || + request.requestedUser.normalizedUserName || + request.requestedByAlias || ''; + } + // Handle string format (fallback for compatibility) + return request.requestedUser || request.requestedByAlias || ''; +} + +export function renderRequests() { + const requestsList = document.getElementById('requests-list'); + const noRequests = document.getElementById('no-requests'); + + if (!requestsList) return; + + const ombiRequests = state.ombiRequests || { movie: [], tv: [] }; + const allRequests = [ + ...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })), + ...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' })) + ]; + + // Apply client-side filters, sorting, and search + const filtered = applyRequestFilters(allRequests, { + types: state.selectedRequestTypes, + statuses: state.selectedRequestStatuses, + sort: state.requestSortMode, + search: state.requestSearchQuery + }); + + requestsList.innerHTML = ''; + + if (filtered.length === 0) { + if (noRequests) { + noRequests.style.display = 'block'; + const p = noRequests.querySelector('p'); + if (p) { + // Differentiate between no data from Ombi vs filters excluded everything + const hasAnyData = allRequests.length > 0; + p.textContent = hasAnyData + ? 'No requests match your filters.' + : 'No requests found.'; + } + } + return; + } + + if (noRequests) noRequests.style.display = 'none'; + + filtered.forEach(request => { + const card = createRequestCard(request); + requestsList.appendChild(card); + }); +} + +function createRequestCard(request) { + if (!request) { + const card = document.createElement('div'); + card.className = 'request-card'; + card.textContent = 'Invalid request data'; + return card; + } + + const card = document.createElement('div'); + card.className = 'request-card'; + + const typeIcon = document.createElement('span'); + typeIcon.className = `request-type-icon ${request.mediaType || ''}`; + typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺'; + + const content = document.createElement('div'); + content.className = 'request-content'; + + const title = document.createElement('div'); + title.className = 'request-title'; + title.textContent = request.title || 'Unknown Title'; + + const meta = document.createElement('div'); + meta.className = 'request-meta'; + + const statusBadge = createStatusBadge(request); + meta.appendChild(statusBadge); + + if (request.year) { + const year = document.createElement('span'); + year.className = 'request-year'; + year.textContent = request.year; + meta.appendChild(year); + } + + const username = extractRequestedUser(request); + if (username) { + const user = document.createElement('span'); + user.className = 'request-user'; + user.textContent = `Requested by: ${username}`; + meta.appendChild(user); + } + + if (request.quality) { + const quality = document.createElement('span'); + quality.className = 'request-quality'; + quality.textContent = request.quality; + meta.appendChild(quality); + } + + content.appendChild(title); + content.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'request-actions'; + + if (state.ombiBaseUrl && request.theMovieDbId) { + const ombiLink = document.createElement('a'); + ombiLink.className = 'request-link ombi-link'; + ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`; + ombiLink.target = '_blank'; + ombiLink.title = 'View in Ombi'; + + const ombiIcon = document.createElement('img'); + ombiIcon.src = '/images/ombi.svg'; + ombiIcon.alt = 'Ombi'; + ombiIcon.className = 'request-icon'; + + ombiLink.appendChild(ombiIcon); + actions.appendChild(ombiLink); + } + + card.appendChild(typeIcon); + card.appendChild(content); + card.appendChild(actions); + + return card; +} + +function createStatusBadge(request) { + const badge = document.createElement('span'); + badge.className = 'request-status-badge'; + + const status = getRequestStatus(request); + const statusTexts = { + available: 'Available', + denied: `Denied: ${request.deniedReason || 'No reason'}`, + approved: 'Approved', + pending: 'Pending', + unknown: 'Unknown' + }; + + badge.classList.add(status); + badge.textContent = statusTexts[status] || 'Unknown'; + + return badge; +} + diff --git a/client/src/ui/tabs.js b/client/src/ui/tabs.js index 94d1e35..0cbbfb1 100644 --- a/client/src/ui/tabs.js +++ b/client/src/ui/tabs.js @@ -2,42 +2,62 @@ import { getActiveTab, saveActiveTab } from '../utils/storage.js'; import { loadHistory } from './history.js'; +import { renderRequests } from './requests.js'; export function initTabs() { const downloadsTab = document.querySelector('[data-tab="downloads"]'); + const requestsTab = document.querySelector('[data-tab="requests"]'); const historyTab = document.querySelector('[data-tab="history"]'); if (!downloadsTab || !historyTab) return; // Load saved tab const savedTab = getActiveTab(); - if (savedTab === 'history') { + if (savedTab === 'requests') { + activateTab('requests'); + } else if (savedTab === 'history') { activateTab('history'); } else { activateTab('downloads'); } downloadsTab.addEventListener('click', () => activateTab('downloads')); + if (requestsTab) { + requestsTab.addEventListener('click', () => activateTab('requests')); + } historyTab.addEventListener('click', () => activateTab('history')); } export function activateTab(tab) { const downloadsTab = document.querySelector('[data-tab="downloads"]'); + const requestsTab = document.querySelector('[data-tab="requests"]'); const historyTab = document.querySelector('[data-tab="history"]'); const downloadsSection = document.getElementById('tab-downloads'); + const requestsSection = document.getElementById('tab-requests'); const historySection = document.getElementById('tab-history'); + // Remove active class from all tabs + if (downloadsTab) downloadsTab.classList.remove('active'); + if (requestsTab) requestsTab.classList.remove('active'); + if (historyTab) historyTab.classList.remove('active'); + + // Hide all sections + if (downloadsSection) downloadsSection.classList.add('hidden'); + if (requestsSection) requestsSection.classList.add('hidden'); + if (historySection) historySection.classList.add('hidden'); + if (tab === 'downloads') { - downloadsTab.classList.add('active'); - historyTab.classList.remove('active'); - downloadsSection.classList.remove('hidden'); - historySection.classList.add('hidden'); + if (downloadsTab) downloadsTab.classList.add('active'); + if (downloadsSection) downloadsSection.classList.remove('hidden'); saveActiveTab('downloads'); + } else if (tab === 'requests') { + if (requestsTab) requestsTab.classList.add('active'); + if (requestsSection) requestsSection.classList.remove('hidden'); + saveActiveTab('requests'); + renderRequests(); } else if (tab === 'history') { - historyTab.classList.add('active'); - downloadsTab.classList.remove('active'); - historySection.classList.remove('hidden'); - downloadsSection.classList.add('hidden'); + if (historyTab) historyTab.classList.add('active'); + if (historySection) historySection.classList.remove('hidden'); saveActiveTab('history'); loadHistory(); } diff --git a/client/src/ui/webhooks.js b/client/src/ui/webhooks.js index 55f720e..9da0a12 100644 --- a/client/src/ui/webhooks.js +++ b/client/src/ui/webhooks.js @@ -1,7 +1,7 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. import { state } from '../state.js'; -import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js'; +import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, enableOmbiWebhook as apiEnableOmbiWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../api.js'; import { formatTimeAgo } from '../utils/format.js'; export function initWebhooks() { @@ -13,8 +13,10 @@ export function initWebhooks() { 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('enable-ombi-webhook').addEventListener('click', enableOmbiWebhook); document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook); document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook); + document.getElementById('test-ombi-webhook').addEventListener('click', testOmbiWebhook); } export function toggleWebhookSection() { @@ -58,9 +60,9 @@ export function renderWebhookStatus() { 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'); - if (sonarrWebhook.enabled) { + sonarrStatus.textContent = state.sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'; + sonarrStatus.className = 'status-indicator ' + (state.sonarrWebhook.enabled ? 'enabled' : 'disabled'); + if (state.sonarrWebhook.enabled) { sonarrEnableBtn.classList.add('hidden'); sonarrTestBtn.classList.remove('hidden'); sonarrTriggers.classList.remove('hidden'); @@ -70,22 +72,22 @@ export function renderWebhookStatus() { sonarrTriggers.classList.add('hidden'); } - 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 (state.sonarrWebhook.enabled) { + document.getElementById('sonarr-onGrab').textContent = state.sonarrWebhook.triggers.onGrab ? '✓' : '✗'; + document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'); + document.getElementById('sonarr-onDownload').textContent = state.sonarrWebhook.triggers.onDownload ? '✓' : '✗'; + document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'); + document.getElementById('sonarr-onImport').textContent = state.sonarrWebhook.triggers.onImport ? '✓' : '✗'; + document.getElementById('sonarr-onImport').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onImport ? 'active' : 'inactive'); + document.getElementById('sonarr-onUpgrade').textContent = state.sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'; + document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); } - if (sonarrWebhook.stats) { + if (state.sonarrWebhook.stats) { sonarrStats.classList.remove('hidden'); - 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); + document.getElementById('sonarr-events').textContent = state.sonarrWebhook.stats.eventsReceived ?? 0; + document.getElementById('sonarr-polls').textContent = state.sonarrWebhook.stats.pollsSkipped ?? 0; + document.getElementById('sonarr-last').textContent = formatTimeAgo(state.sonarrWebhook.stats.lastWebhookTimestamp); } else { sonarrStats.classList.add('hidden'); } @@ -97,9 +99,9 @@ export function renderWebhookStatus() { 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'); - if (radarrWebhook.enabled) { + radarrStatus.textContent = state.radarrWebhook.enabled ? '● Enabled' : '○ Disabled'; + radarrStatus.className = 'status-indicator ' + (state.radarrWebhook.enabled ? 'enabled' : 'disabled'); + if (state.radarrWebhook.enabled) { radarrEnableBtn.classList.add('hidden'); radarrTestBtn.classList.remove('hidden'); radarrTriggers.classList.remove('hidden'); @@ -109,25 +111,66 @@ export function renderWebhookStatus() { radarrTriggers.classList.add('hidden'); } - 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 (state.radarrWebhook.enabled) { + document.getElementById('radarr-onGrab').textContent = state.radarrWebhook.triggers.onGrab ? '✓' : '✗'; + document.getElementById('radarr-onGrab').className = 'trigger-value ' + (state.radarrWebhook.triggers.onGrab ? 'active' : 'inactive'); + document.getElementById('radarr-onDownload').textContent = state.radarrWebhook.triggers.onDownload ? '✓' : '✗'; + document.getElementById('radarr-onDownload').className = 'trigger-value ' + (state.radarrWebhook.triggers.onDownload ? 'active' : 'inactive'); + document.getElementById('radarr-onImport').textContent = state.radarrWebhook.triggers.onImport ? '✓' : '✗'; + document.getElementById('radarr-onImport').className = 'trigger-value ' + (state.radarrWebhook.triggers.onImport ? 'active' : 'inactive'); + document.getElementById('radarr-onUpgrade').textContent = state.radarrWebhook.triggers.onUpgrade ? '✓' : '✗'; + document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (state.radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); } - if (radarrWebhook.stats) { + if (state.radarrWebhook.stats) { radarrStats.classList.remove('hidden'); - 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); + document.getElementById('radarr-events').textContent = state.radarrWebhook.stats.eventsReceived ?? 0; + document.getElementById('radarr-polls').textContent = state.radarrWebhook.stats.pollsSkipped ?? 0; + document.getElementById('radarr-last').textContent = formatTimeAgo(state.radarrWebhook.stats.lastWebhookTimestamp); } else { radarrStats.classList.add('hidden'); } + + // Ombi + const ombiStatus = document.getElementById('ombi-status'); + const ombiEnableBtn = document.getElementById('enable-ombi-webhook'); + const ombiTestBtn = document.getElementById('test-ombi-webhook'); + const ombiTriggers = document.getElementById('ombi-triggers'); + const ombiStats = document.getElementById('ombi-stats'); + + ombiStatus.textContent = state.ombiWebhook.enabled ? '● Enabled' : '○ Disabled'; + ombiStatus.className = 'status-indicator ' + (state.ombiWebhook.enabled ? 'enabled' : 'disabled'); + if (state.ombiWebhook.enabled) { + ombiEnableBtn.classList.add('hidden'); + ombiTestBtn.classList.remove('hidden'); + ombiTriggers.classList.remove('hidden'); + } else { + ombiEnableBtn.classList.remove('hidden'); + ombiTestBtn.classList.add('hidden'); + ombiTriggers.classList.add('hidden'); + } + + if (state.ombiWebhook.enabled) { + document.getElementById('ombi-requestAvailable').textContent = state.ombiWebhook.triggers.requestAvailable ? '✓' : '✗'; + document.getElementById('ombi-requestAvailable').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestAvailable ? 'active' : 'inactive'); + document.getElementById('ombi-requestApproved').textContent = state.ombiWebhook.triggers.requestApproved ? '✓' : '✗'; + document.getElementById('ombi-requestApproved').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestApproved ? 'active' : 'inactive'); + document.getElementById('ombi-requestDeclined').textContent = state.ombiWebhook.triggers.requestDeclined ? '✓' : '✗'; + document.getElementById('ombi-requestDeclined').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestDeclined ? 'active' : 'inactive'); + document.getElementById('ombi-requestPending').textContent = state.ombiWebhook.triggers.requestPending ? '✓' : '✗'; + document.getElementById('ombi-requestPending').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestPending ? 'active' : 'inactive'); + document.getElementById('ombi-requestProcessing').textContent = state.ombiWebhook.triggers.requestProcessing ? '✓' : '✗'; + document.getElementById('ombi-requestProcessing').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestProcessing ? 'active' : 'inactive'); + } + + if (state.ombiWebhook.stats) { + ombiStats.classList.remove('hidden'); + document.getElementById('ombi-events').textContent = state.ombiWebhook.stats.eventsReceived ?? 0; + document.getElementById('ombi-polls').textContent = state.ombiWebhook.stats.pollsSkipped ?? 0; + document.getElementById('ombi-last').textContent = formatTimeAgo(state.ombiWebhook.stats.lastWebhookTimestamp); + } else { + ombiStats.classList.add('hidden'); + } } export async function enableSonarrWebhook() { @@ -198,12 +241,48 @@ export async function testRadarrWebhook() { } } +export async function enableOmbiWebhook() { + setWebhookLoading(true); + try { + const result = await apiEnableOmbiWebhook(); + if (!result.success) { + console.error('Failed to enable Ombi webhook:', result.error); + alert('Failed to enable Ombi webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to enable Ombi webhook:', err); + alert('Failed to enable Ombi webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + +export async function testOmbiWebhook() { + setWebhookLoading(true); + try { + const result = await apiTestOmbiWebhook(); + if (result.success) { + alert('Ombi webhook test sent successfully!'); + } else { + console.error('Failed to test Ombi webhook:', result.error); + alert('Failed to test Ombi webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to test Ombi webhook:', err); + alert('Failed to test Ombi webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + export function setWebhookLoading(loading) { state.webhookLoading = loading; document.getElementById('enable-sonarr-webhook').disabled = loading; document.getElementById('enable-radarr-webhook').disabled = loading; + document.getElementById('enable-ombi-webhook').disabled = loading; document.getElementById('test-sonarr-webhook').disabled = loading; document.getElementById('test-radarr-webhook').disabled = loading; + document.getElementById('test-ombi-webhook').disabled = loading; const loadingEl = document.getElementById('webhook-loading'); if (loading) { loadingEl.classList.remove('hidden'); diff --git a/client/src/utils/ombiFilters.js b/client/src/utils/ombiFilters.js new file mode 100644 index 0000000..06458e7 --- /dev/null +++ b/client/src/utils/ombiFilters.js @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +/** + * Pure filter / sort / search utilities for Ombi requests. + * Must stay in sync with server/utils/ombiFilters.js + */ + +/** + * Derive a single status string from an Ombi request object. + * Priority: available > denied > approved > pending > unknown + * + * @param {Object} request + * @returns {string} + */ +export function getRequestStatus(request) { + if (!request) return 'unknown'; + if (request.available) return 'available'; + if (request.denied) return 'denied'; + if (request.approved) return 'approved'; + if (request.requested) return 'pending'; + return 'unknown'; +} + +/** + * Filter requests by media type. + * + * @param {Array} requests + * @param {string[]} types + * @returns {Array} + */ +export function filterByType(requests, types) { + if (!types || types.length === 0) return requests; + const normalized = types.map(t => t.toLowerCase()); + if (normalized.includes('all')) return requests; + return requests.filter(r => normalized.includes(r.mediaType)); +} + +/** + * Filter requests by status. + * + * @param {Array} requests + * @param {string[]} statuses + * @returns {Array} + */ +export function filterByStatus(requests, statuses) { + if (!statuses || statuses.length === 0) return requests; + const normalized = statuses.map(s => s.toLowerCase()); + return requests.filter(r => normalized.includes(getRequestStatus(r))); +} + +/** + * Filter requests by case-insensitive title substring. + * + * @param {Array} requests + * @param {string} query + * @returns {Array} + */ +export function filterBySearch(requests, query) { + if (!query || query.trim() === '') return requests; + const q = query.trim().toLowerCase(); + return requests.filter(r => (r.title || '').toLowerCase().includes(q)); +} + +/** + * Sort requests by the given sort mode. + * + * @param {Array} requests + * @param {string} sortMode + * @returns {Array} + */ +export function sortRequests(requests, sortMode) { + const sorted = [...requests]; + switch (sortMode) { + case 'requestedDate_asc': + return sorted.sort((a, b) => { + const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0; + const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0; + return da - db; + }); + case 'title_asc': + return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '')); + case 'title_desc': + return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || '')); + case 'requestedDate_desc': + default: + return sorted.sort((a, b) => { + const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0; + const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0; + return db - da; + }); + } +} + +/** + * Apply all filters and sorting in one call. + * + * @param {Array} requests + * @param {Object} options + * @returns {Array} + */ +export function applyRequestFilters(requests, { types, statuses, sort, search } = {}) { + let result = [...requests]; + result = filterByType(result, types); + result = filterByStatus(result, statuses); + result = filterBySearch(result, search); + result = sortRequests(result, sort); + return result; +} diff --git a/client/src/utils/storage.js b/client/src/utils/storage.js index 73a9a64..2530b3d 100644 --- a/client/src/utils/storage.js +++ b/client/src/utils/storage.js @@ -46,6 +46,41 @@ import { state } from '../state.js'; } })(); +// Load request filter preferences from localStorage +(function loadRequestFilters() { + try { + const savedTypes = localStorage.getItem('sofarr-request-types'); + if (savedTypes) state.selectedRequestTypes = JSON.parse(savedTypes); + } catch (e) { + console.error('[Storage] Failed to load request types:', e); + state.selectedRequestTypes = ['movie', 'tv']; + } + + try { + const savedStatuses = localStorage.getItem('sofarr-request-statuses'); + if (savedStatuses) state.selectedRequestStatuses = JSON.parse(savedStatuses); + } catch (e) { + console.error('[Storage] Failed to load request statuses:', e); + state.selectedRequestStatuses = []; + } + + try { + const savedSort = localStorage.getItem('sofarr-request-sort'); + if (savedSort) state.requestSortMode = savedSort; + } catch (e) { + console.error('[Storage] Failed to load request sort:', e); + state.requestSortMode = 'requestedDate_desc'; + } + + try { + const savedSearch = localStorage.getItem('sofarr-request-search'); + if (savedSearch !== null) state.requestSearchQuery = savedSearch; + } catch (e) { + console.error('[Storage] Failed to load request search:', e); + state.requestSearchQuery = ''; + } +})(); + // Export helper functions for localStorage operations export function saveHistoryDays(days) { localStorage.setItem('sofarr-history-days', days); @@ -74,3 +109,19 @@ export function getActiveTab() { export function saveActiveTab(tab) { localStorage.setItem('sofarr-active-tab', tab); } + +export function saveRequestTypes(types) { + localStorage.setItem('sofarr-request-types', JSON.stringify(types)); +} + +export function saveRequestStatuses(statuses) { + localStorage.setItem('sofarr-request-statuses', JSON.stringify(statuses)); +} + +export function saveRequestSort(sort) { + localStorage.setItem('sofarr-request-sort', sort); +} + +export function saveRequestSearch(query) { + localStorage.setItem('sofarr-request-search', query); +} diff --git a/package-lock.json b/package-lock.json index c7a9543..e83a923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.0", + "version": "1.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.0", + "version": "1.7.3", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index ca9e8ce..004ddbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.1", + "version": "1.7.3", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/public/app.js b/public/app.js index 116c5c6..94a18df 100644 --- a/public/app.js +++ b/public/app.js @@ -1,11 +1,11 @@ -const o={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},webhookMetrics:null},le=1200,ce=5*60*1e3,de=5e3;async function ue(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),n=await e.json(),s=await t.json();return s.csrfToken&&(o.csrfToken=s.csrfToken),n.authenticated?(o.currentUser=n.user,o.isAdmin=!!n.user.isAdmin,{authenticated:!0,user:n.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function me(e,t,n){try{const a=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:n})})).json();return a.success?(o.currentUser=a.user,o.isAdmin=!!a.user.isAdmin,a.csrfToken&&(o.csrfToken=a.csrfToken),{success:!0,user:a.user}):{success:!1,error:a.error||"Login failed"}}catch(s){return console.error(s),{success:!1,error:"Login failed. Please try again."}}}async function he(){try{return await fetch("/api/auth/logout",{method:"POST",headers:csrfToken?{"X-CSRF-Token":csrfToken}:{}}),currentUser=null,csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function fe(e=!1){try{const t=new URLSearchParams({days:o.historyDays});o.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const n=await fetch(`/api/history/recent?${t}`);if(!n.ok)throw new Error(`HTTP ${n.status}`);return{success:!0,history:(await n.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function pe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentType:e.arrContentType})});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function ge(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function ye(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function N(){var e,t;try{const n=ye();let s=!1,a={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const f=await fetch("/api/sonarr/notifications");if(f.ok){const d=(await f.json()).find(p=>p.name==="Sofarr");s=!!d,d&&(a={onGrab:d.onGrab,onDownload:d.onDownload,onImport:d.onImport,onUpgrade:d.onUpgrade})}}catch{}let l=!1,c={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const f=await fetch("/api/radarr/notifications");if(f.ok){const d=(await f.json()).find(p=>p.name==="Sofarr");l=!!d,d&&(c={onGrab:d.onGrab,onDownload:d.onDownload,onImport:d.onImport,onUpgrade:d.onUpgrade})}}catch{}o.webhookMetrics=await n;const m=o.webhookMetrics?Object.entries(o.webhookMetrics.instances||{}):[],u=((e=m.find(([f])=>f.includes("sonarr")))==null?void 0:e[1])||null,i=((t=m.find(([f])=>f.includes("radarr")))==null?void 0:t[1])||null;return o.sonarrWebhook={enabled:s,triggers:a,stats:u},o.radarrWebhook={enabled:l,triggers:c,stats:i},{success:!0}}catch(n){return console.error("Failed to fetch webhook status:",n),{success:!1}}}async function be(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":csrfToken||""}})).ok)throw new Error("Failed to enable");return await N(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function ve(){try{if(!(await fetch("/api/radarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Failed to enable");return await N(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function Ee(){try{const e=await fetch("/api/sonarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const n=(await e.json()).find(a=>a.name==="Sofarr");if(!n)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/sonarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":o.csrfToken||""},body:JSON.stringify(n)})).ok)throw new Error("Test failed");return await N(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function we(){try{const e=await fetch("/api/radarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const n=(await e.json()).find(a=>a.name==="Sofarr");if(!n)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/radarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":o.csrfToken||""},body:JSON.stringify(n)})).ok)throw new Error("Test failed");return await N(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function ke(){try{const e=await fetch("/api/dashboard/status");if(!e.ok)throw new Error("Failed to fetch status: "+e.status);return{success:!0,data:await e.json()}}catch(e){return console.error("[Status] Error fetching status:",e),{success:!1,error:e.message}}}function Se(e){if(!e)return"N/A";if(typeof e=="string")return e;const t=["B","KB","MB","GB","TB"],n=Math.floor(Math.log(e)/Math.log(1024));return Math.round(e/Math.pow(1024,n)*100)/100+" "+t[n]}function Ce(e){if(!e||e===0)return"0 B/s";const t=["B/s","KB/s","MB/s","GB/s"];let n=e,s=0;for(;n>=1024&&s{const a="S"+String(s.season).padStart(2,"0")+"E"+String(s.episode).padStart(2,"0");return s.title?a+" — "+s.title:a});t.setAttribute("data-tooltip",n.join(` -`))}return t}function R(e,t,n){const s=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(c=>!c.matchedUser),l=e.filter(c=>c.matchedUser);for(const c of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=c.label,s.appendChild(m)}for(const c of l){const m=document.createElement("span");m.className="download-user-badge",m.textContent=c.matchedUser,s.appendChild(m)}}else if(n){const a=document.createElement("span");a.className="download-user-badge",a.textContent=n,s.appendChild(a)}return s}function X(e){const t=document.createElement("span");t.className="download-client-logo-wrapper download-card-logo-wrapper";const n=document.createElement("img");return n.className="download-client-logo",n.src=`/images/clients/${e.client}.svg`,n.alt=`${e.instanceName||e.client} icon`,n.title=e.instanceName||e.client,n.onerror=()=>{t.textContent=e.client.charAt(0).toUpperCase(),t.classList.add("fallback")},t.appendChild(n),t}function Q(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let n=o.downloads;if(o.selectedDownloadClients.length>0){const l=o.selectedDownloadClients.map(c=>o.downloadClients[c]).filter(Boolean);n=o.downloads.filter(c=>l.some(m=>m.type===c.client&&m.id===c.instanceId))}if(o.downloadClients.length>0){const l=new Map(o.downloadClients.map((c,m)=>[c.id,m]));n=[...n].sort((c,m)=>{const u=l.get(c.instanceId)??1/0,i=l.get(m.instanceId)??1/0;return u-i})}if(n.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const s=new Map;e.querySelectorAll(".download-card").forEach(l=>{s.set(l.dataset.id,l)});const a=new Set;n.forEach(l=>{const c=l.title;a.add(c);const m=s.get(c);if(m)Le(m,l);else{const u=Te(l);e.appendChild(u)}}),s.forEach((l,c)=>{a.has(c)||l.remove()})}function Le(e,t){const n=e.querySelector(".download-header-right");n&&n.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(r=>r.remove());const a=e.querySelector(".download-header .download-client-logo-wrapper");a&&a.remove();const l=e.querySelector(".download-card-logo-wrapper");l&&l.remove();const c=e.querySelector(".download-header");if(c&&!c.querySelector(".download-header-right")){const r=document.createElement("div");r.className="download-header-right";const d=R(t.tagBadges,o.showAll,t.matchedUserTag);r.appendChild(d),c.appendChild(r)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(X(t));const m=e.querySelector(".download-status");m&&m.textContent!==t.status&&(m.textContent=t.status,m.className=`download-status ${t.status}`);const u=e.querySelector(".progress-container");if(u&&t.progress!==void 0){const r=u.querySelector(".progress-bar"),d=u.querySelector(".progress-text"),p=u.querySelector(".missing-text");if(r){const g=r.querySelector(".downloaded");g&&(g.style.width=t.progress+"%")}if(d&&(d.textContent=t.progress+"%"),p){const g=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&g>0?p.textContent=`(missing ${y.toFixed(1)} of ${g.toFixed(1)} MB)`:p.textContent=""}}const i=e.querySelector('.detail-item[data-label="Speed"] .detail-value');i&&t.speed!==void 0&&(i.textContent=t.speed);const f=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(f&&t.eta!==void 0&&(f.textContent=t.eta),t.qbittorrent){const r=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');r&&t.seeds!==void 0&&(r.textContent=t.seeds);const d=e.querySelector('.detail-item[data-label="Peers"] .detail-value');d&&t.peers!==void 0&&(d.textContent=t.peers);const p=e.querySelector('.detail-item[data-label="Availability"]');p&&t.availability!==void 0&&(p.querySelector(".detail-value").textContent=`${t.availability}%`,p.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function Be(e,t){if(confirm(`Blocklist "${t.title}" and trigger a new search? +const n={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,ombiBaseUrl:null,ombiRequests:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},ombiWebhook:{enabled:!1,triggers:{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},stats:null},webhookMetrics:null,selectedRequestTypes:["movie","tv"],selectedRequestStatuses:[],requestSortMode:"requestedDate_desc",requestSearchQuery:""},ke=1200,we=5*60*1e3,Se=5e3;async function Ce(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),s=await e.json(),o=await t.json();return o.csrfToken&&(n.csrfToken=o.csrfToken),s.authenticated?(n.currentUser=s.user,n.isAdmin=!!s.user.isAdmin,{authenticated:!0,user:s.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function Ie(e,t,s){try{const a=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:s})})).json();return a.success?(n.currentUser=a.user,n.isAdmin=!!a.user.isAdmin,a.csrfToken&&(n.csrfToken=a.csrfToken),{success:!0,user:a.user}):{success:!1,error:a.error||"Login failed"}}catch(o){return console.error(o),{success:!1,error:"Login failed. Please try again."}}}async function Be(){try{return await fetch("/api/auth/logout",{method:"POST",headers:n.csrfToken?{"X-CSRF-Token":n.csrfToken}:{}}),n.currentUser=null,n.csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function Le(e=!1){try{const t=new URLSearchParams({days:n.historyDays});n.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const s=await fetch(`/api/history/recent?${t}`);if(!s.ok)throw new Error(`HTTP ${s.status}`);return{success:!0,history:(await s.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function qe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentType:e.arrContentType})});if(!t.ok){const s=await t.json().catch(()=>({}));throw new Error(s.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function Te(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function xe(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function q(){var e,t;try{const s=xe();let o=!1;try{const h=await fetch("/api/webhook/config");h.ok&&(o=(await h.json()).valid||!1)}catch{}let a=!1,r={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/sonarr/notifications");if(h.ok){const b=(await h.json()).find(E=>E.name==="Sofarr");a=o&&!!b,b&&(r={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let c=!1,m={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/radarr/notifications");if(h.ok){const b=(await h.json()).find(E=>E.name==="Sofarr");c=o&&!!b,b&&(m={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let d=!1,l={requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},f=null;try{const h=await fetch("/api/ombi/webhook/status");if(h.ok){const y=await h.json();d=y.enabled||!1,l=y.triggers||{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},f=y.stats||null}}catch{}n.webhookMetrics=await s;const i=n.webhookMetrics?Object.entries(n.webhookMetrics.instances||{}):[],u=((e=i.find(([h])=>h.includes("sonarr")))==null?void 0:e[1])||null,p=((t=i.find(([h])=>h.includes("radarr")))==null?void 0:t[1])||null;return n.sonarrWebhook={enabled:a,triggers:r,stats:u},n.radarrWebhook={enabled:c,triggers:m,stats:p},n.ombiWebhook={enabled:d,triggers:l,stats:f},{success:!0}}catch(s){return console.error("Failed to fetch webhook status:",s),{success:!1}}}async function Ne(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function Re(){try{if(!(await fetch("/api/radarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function De(){try{const e=await fetch("/api/sonarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const s=(await e.json()).find(a=>a.name==="Sofarr");if(!s)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/sonarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function Ae(){try{const e=await fetch("/api/radarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const s=(await e.json()).find(a=>a.name==="Sofarr");if(!s)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/radarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function Fe(){try{if(!(await fetch("/api/ombi/webhook/enable",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Ombi webhook:",e),{success:!1,error:e.message}}}async function Me(){try{if(!(await fetch("/api/ombi/webhook/test",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Ombi webhook:",e),{success:!1,error:e.message}}}async function $e(){try{const e=await fetch("/api/status");if(!e.ok)throw new Error("Failed to fetch status: "+e.status);return{success:!0,data:await e.json()}}catch(e){return console.error("[Status] Error fetching status:",e),{success:!1,error:e.message}}}function We(e){if(!e)return"N/A";if(typeof e=="string")return e;const t=["B","KB","MB","GB","TB"],s=Math.floor(Math.log(e)/Math.log(1024));return Math.round(e/Math.pow(1024,s)*100)/100+" "+t[s]}function ne(e){if(!e||e===0)return"0 B/s";const t=["B/s","KB/s","MB/s","GB/s"];let s=e,o=0;for(;s>=1024&&o{const a="S"+String(o.season).padStart(2,"0")+"E"+String(o.episode).padStart(2,"0");return o.title?a+" — "+o.title:a});t.setAttribute("data-tooltip",s.join(` +`))}return t}function $(e,t,s){const o=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(c=>!c.matchedUser),r=e.filter(c=>c.matchedUser);for(const c of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=c.label,o.appendChild(m)}for(const c of r){const m=document.createElement("span");m.className="download-user-badge",m.textContent=c.matchedUser,o.appendChild(m)}}else if(s){const a=document.createElement("span");a.className="download-user-badge",a.textContent=s,o.appendChild(a)}return o}function ae(e){const t=document.createElement("span");t.className="download-client-logo-wrapper download-card-logo-wrapper";const s=document.createElement("img");return s.className="download-client-logo",s.src=`/images/clients/${e.client}.svg`,s.alt=`${e.instanceName||e.client} icon`,s.title=e.instanceName||e.client,s.onerror=()=>{t.textContent=e.client.charAt(0).toUpperCase(),t.classList.add("fallback")},t.appendChild(s),t}function K(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi",s.href=e.ombiLink;const o=document.createElement("a");o.href=e.ombiLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}if(n.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const o=document.createElement("a");o.href=e.arrLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}return t}function W(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let s=n.downloads;if(n.selectedDownloadClients.length>0){const r=n.selectedDownloadClients.map(c=>n.downloadClients[c]).filter(Boolean);s=n.downloads.filter(c=>r.some(m=>m.type===c.client&&m.id===c.instanceId))}if(n.downloadClients.length>0){const r=new Map(n.downloadClients.map((c,m)=>[c.id,m]));s=[...s].sort((c,m)=>{const d=r.get(c.instanceId)??1/0,l=r.get(m.instanceId)??1/0;return d-l})}if(s.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const o=new Map;e.querySelectorAll(".download-card").forEach(r=>{o.set(r.dataset.id,r)});const a=new Set;s.forEach(r=>{const c=r.title;a.add(c);const m=o.get(c);if(m)Ue(m,r);else{const d=Oe(r);e.appendChild(d)}}),o.forEach((r,c)=>{a.has(c)||r.remove()})}function Ue(e,t){const s=e.querySelector(".download-header-right");s&&s.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(i=>i.remove());const a=e.querySelector(".download-header .download-client-logo-wrapper");a&&a.remove();const r=e.querySelector(".download-card-logo-wrapper");r&&r.remove();const c=e.querySelector(".download-header");if(c&&!c.querySelector(".download-header-right")){const i=document.createElement("div");i.className="download-header-right";const u=$(t.tagBadges,n.showAll,t.matchedUserTag);i.appendChild(u),c.appendChild(i)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(ae(t));const m=e.querySelector(".download-status");m&&m.textContent!==t.status&&(m.textContent=t.status,m.className=`download-status ${t.status}`);const d=e.querySelector(".progress-container");if(d&&t.progress!==void 0){const i=d.querySelector(".progress-bar"),u=d.querySelector(".progress-text"),p=d.querySelector(".missing-text");if(i){const h=i.querySelector(".downloaded");h&&(h.style.width=t.progress+"%")}if(u&&(u.textContent=t.progress+"%"),p){const h=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&h>0?p.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`:p.textContent=""}}const l=e.querySelector('.detail-item[data-label="Speed"] .detail-value');l&&t.speed!==void 0&&(l.textContent=ne(t.speed));const f=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(f&&t.eta!==void 0&&(f.textContent=t.eta),t.qbittorrent){const i=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');i&&t.seeds!==void 0&&(i.textContent=t.seeds);const u=e.querySelector('.detail-item[data-label="Peers"] .detail-value');u&&t.peers!==void 0&&(u.textContent=t.peers);const p=e.querySelector('.detail-item[data-label="Availability"]');p&&t.availability!==void 0&&(p.querySelector(".detail-value").textContent=`${t.availability}%`,p.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function Pe(e,t){if(console.log("[Blocklist] Clicked, download:",t),console.log("[Blocklist] Required fields:",{arrQueueId:t.arrQueueId,arrType:t.arrType,arrInstanceUrl:t.arrInstanceUrl,arrInstanceKey:t.arrInstanceKey,arrContentId:t.arrContentId,arrContentType:t.arrContentType,isAdmin:n.isAdmin,canBlocklist:t.canBlocklist}),!!confirm(`Blocklist "${t.title}" and trigger a new search? This will: • Remove the download from the download client • Add this release to the blocklist -• Trigger an automatic search for a new release`)){e.disabled=!0,e.textContent="⏳ Working…";try{await pe(t),e.textContent="✓ Done — searching…",e.className="blocklist-search-btn success"}catch(n){console.error("[Blocklist] Error:",n),e.disabled=!1,e.textContent="⛔ Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${n.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search"},4e3)}}}function Te(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const r=document.createElement("div");r.className="download-cover";const d=document.createElement("img");d.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",d.alt=e.movieName||e.seriesName||e.title,d.loading="lazy",r.appendChild(d),t.appendChild(r)}const n=document.createElement("div");n.className="download-info";const s=document.createElement("div");s.className="download-header";const a=document.createElement("span");if(a.className=`download-type ${e.type}`,e.type==="series")a.textContent="📺 Series";else if(e.type==="movie")a.textContent="🎬 Movie";else if(e.type==="torrent"){const r=e.instanceName?` (${e.instanceName})`:"";a.textContent=`📥 Torrent${r}`}else a.textContent=e.type;const l=document.createElement("span");if(l.className=`download-status ${e.status}`,l.textContent=e.status,s.appendChild(a),s.appendChild(l),e.importIssues&&e.importIssues.length>0){const r=document.createElement("span");r.className="import-issue-badge",r.textContent="Import Pending",r.setAttribute("data-tooltip",e.importIssues.join(` -`)),s.appendChild(r)}if((o.isAdmin||e.canBlocklist)&&e.arrQueueId){const r=document.createElement("button");r.className="blocklist-search-btn",r.textContent="⛔ Blocklist & Search",r.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",r.addEventListener("click",()=>Be(r,e)),s.appendChild(r)}const c=document.createElement("div");c.className="download-header-right";const m=R(e.tagBadges,o.showAll,e.matchedUserTag);c.appendChild(m),s.appendChild(c),e.client&&t.appendChild(X(e));const u=document.createElement("h3");if(u.className="download-title",u.textContent=e.title,n.appendChild(s),n.appendChild(u),e.seriesName){const r=document.createElement("p");r.className="download-series",o.isAdmin&&e.arrLink?r.innerHTML='Series: '+v(e.seriesName)+"":r.textContent=`Series: ${e.seriesName}`,n.appendChild(r);const d=K(e.episodes);d&&n.appendChild(d)}if(e.movieName){const r=document.createElement("p");r.className="download-movie",o.isAdmin&&e.arrLink?r.innerHTML='Movie: '+v(e.movieName)+"":r.textContent=`Movie: ${e.movieName}`,n.appendChild(r)}const i=document.createElement("div");i.className="download-details";const f=C("Size",Se(e.size));if(i.appendChild(f),e.progress!==void 0){const r=document.createElement("div");r.className="detail-item progress-item",r.dataset.label="Progress";const d=document.createElement("span");d.className="detail-label",d.textContent="Progress";const p=document.createElement("div");p.className="progress-container";const g=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,B=parseFloat(e.progress)||0,I=document.createElement("div");if(I.className="progress-bar",B>0){const b=document.createElement("div");b.className="progress-segment downloaded",b.style.width=B+"%",I.appendChild(b)}p.appendChild(I);const L=document.createElement("span");if(L.className="progress-text",L.textContent=e.progress+"%",p.appendChild(L),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&y>0&&g>0){const b=document.createElement("span");b.className="missing-text",b.textContent=`(missing ${y.toFixed(1)} of ${g.toFixed(1)} MB)`,p.appendChild(b)}r.appendChild(d),r.appendChild(p),i.appendChild(r)}if(e.speed&&e.speed>0){const r=C("Speed",Ce(e.speed));i.appendChild(r)}if(e.eta){const r=C("ETA",e.eta);i.appendChild(r)}if(e.qbittorrent){if(e.seeds!==void 0){const r=C("Seeds",e.seeds);i.appendChild(r)}if(e.peers!==void 0){const r=C("Peers",e.peers);i.appendChild(r)}if(e.availability!==void 0){const r=C("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&r.classList.add("availability-warning"),i.appendChild(r)}}if(e.completedAt){const r=C("Completed",xe(e.completedAt));i.appendChild(r)}if(o.isAdmin&&(e.downloadPath||e.targetPath)){const r=document.createElement("div");if(r.className="download-paths",e.downloadPath){const d=document.createElement("div");d.className="path-item",d.innerHTML='Download: '+v(e.downloadPath)+"",r.appendChild(d)}if(e.targetPath){const d=document.createElement("div");d.className="path-item",d.innerHTML='Target: '+v(e.targetPath)+"",r.appendChild(d)}i.appendChild(r)}return n.appendChild(i),t.appendChild(n),t}function C(e,t){const n=document.createElement("div");n.className="detail-item",n.dataset.label=e;const s=document.createElement("span");s.className="detail-label",s.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,n.appendChild(s),n.appendChild(a),n}function xe(e){return e?new Date(e).toLocaleString():"N/A"}function F(){V();const e=o.showAll?"?showAll=true":"",t=new EventSource("/api/dashboard/stream"+e);o.sseSource=t;let n=!0;t.onmessage=s=>{try{const a=JSON.parse(s.data);if(o.currentUser=a.user,o.isAdmin=!!a.isAdmin,o.downloads=a.downloads,a.downloadClients){o.downloadClients=a.downloadClients;const l=new CustomEvent("downloadClientsUpdated");document.dispatchEvent(l)}document.getElementById("currentUser").textContent=o.currentUser||"-",Q(),nt(),n&&(n=!1,st())}catch(a){console.error("[SSE] Failed to parse message:",a)}},t.onerror=()=>{console.warn("[SSE] Connection lost, browser will retry...")},console.log("[SSE] Stream connected")}function V(){o.sseSource&&(o.sseSource.close(),o.sseSource=null,console.log("[SSE] Stream closed"))}function Ne(e){o.showAll=e,F();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{o.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(o.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(n){console.error("[Migration] Failed to migrate download client filter:",n)}else try{const n=localStorage.getItem("sofarr-download-clients");o.selectedDownloadClients=n?JSON.parse(n):[]}catch(n){console.error("[Migration] Failed to load download client filter:",n),o.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(o.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");o.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();function De(e){localStorage.setItem("sofarr-history-days",e)}function Me(e){localStorage.setItem("sofarr-ignore-available",e)}function Ae(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function Y(){return localStorage.getItem("sofarr-theme")||"light"}function Re(e){localStorage.setItem("sofarr-theme",e)}function Fe(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function j(e){localStorage.setItem("sofarr-active-tab",e)}function $e(){const e=document.getElementById("history-days"),t=document.getElementById("history-refresh-btn"),n=document.getElementById("ignore-available-toggle");e&&e.addEventListener("change",()=>{const s=parseInt(e.value,10);s>0&&s<=90&&(historyDays=s,De(s),x(!0))}),t&&t.addEventListener("click",()=>x(!0)),n&&(n.checked=o.ignoreAvailable,n.addEventListener("change",()=>{o.ignoreAvailable=n.checked,Me(o.ignoreAvailable),ee(o.lastHistoryItems)})),document.addEventListener("historyReload",()=>{x(!0)})}function He(){Z(),o.historyRefreshHandle=setInterval(()=>x(),ce)}function Z(){o.historyRefreshHandle&&(clearInterval(o.historyRefreshHandle),o.historyRefreshHandle=null)}function We(){o.lastHistoryItems=[],document.getElementById("history-list").innerHTML="",document.getElementById("no-history").classList.add("hidden"),document.getElementById("history-error").classList.add("hidden")}async function x(e=!1){document.getElementById("history-list");const t=document.getElementById("history-loading"),n=document.getElementById("history-error"),s=document.getElementById("no-history");t.classList.remove("hidden"),n.classList.add("hidden"),s.classList.add("hidden");try{const a=await fe(e);t.classList.add("hidden"),a.success?(o.lastHistoryItems=a.history,ee(o.lastHistoryItems)):(n.textContent=a.error||"Failed to load history.",n.classList.remove("hidden"))}catch(a){t.classList.add("hidden"),n.textContent="Failed to load history.",n.classList.remove("hidden"),console.error("[History] Load error:",a)}}function ee(e){const t=document.getElementById("history-list"),n=document.getElementById("no-history");t.innerHTML="";const s=o.ignoreAvailable?e.filter(a=>!(a.outcome==="failed"&&a.availableForUpgrade)):e;if(!s.length){n.classList.remove("hidden");return}n.classList.add("hidden"),s.forEach(a=>t.appendChild(Ue(a)))}function Ue(e){const t=document.createElement("div");if(t.className=`history-card ${e.type} ${e.outcome}`,e.coverArt){const i=document.createElement("div");i.className="history-cover";const f=document.createElement("img");f.src="/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt),f.alt=e.movieName||e.seriesName||e.title,f.loading="lazy",i.appendChild(f),t.appendChild(i)}const n=document.createElement("div");n.className="history-info";const s=document.createElement("div");s.className="history-card-header";const a=document.createElement("span");a.className=`history-type-badge ${e.type}`,a.textContent=e.type==="series"?"📺 Series":"🎬 Movie",s.appendChild(a);const l=document.createElement("span");if(l.className=`history-outcome-badge ${e.outcome}`,l.textContent=e.outcome==="imported"?"✓ Imported":"✗ Failed",s.appendChild(l),e.availableForUpgrade){const i=document.createElement("span");i.className="history-upgrade-badge",i.title="A previous version of this item is available. An upgrade download has failed.",i.textContent="⬆ Available",s.appendChild(i)}if(e.instanceName){const i=document.createElement("span");i.className="history-instance-badge",i.textContent=e.instanceName,s.appendChild(i)}const c=R(e.tagBadges,o.showAll,e.matchedUserTag);s.appendChild(c),n.appendChild(s);const m=document.createElement("h3");if(m.className="history-title",m.textContent=e.title,n.appendChild(m),e.seriesName){const i=document.createElement("p");i.className="history-media-name",o.isAdmin&&e.arrLink?i.innerHTML='Series: '+v(e.seriesName)+"":i.textContent="Series: "+e.seriesName,n.appendChild(i);const f=K(e.episodes);f&&n.appendChild(f)}if(e.movieName){const i=document.createElement("p");i.className="history-media-name",o.isAdmin&&e.arrLink?i.innerHTML='Movie: '+v(e.movieName)+"":i.textContent="Movie: "+e.movieName,n.appendChild(i)}const u=document.createElement("div");if(u.className="history-details",e.completedAt&&u.appendChild(G("Completed",Ie(e.completedAt))),e.quality&&u.appendChild(G("Quality",e.quality)),e.outcome==="failed"&&e.failureMessage){const i=document.createElement("div");i.className="history-failure-message",i.textContent=e.failureMessage,u.appendChild(i)}return n.appendChild(u),t.appendChild(n),t}function G(e,t){const n=document.createElement("div");n.className="detail-item",n.dataset.label=e;const s=document.createElement("span");s.className="detail-label",s.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,n.appendChild(s),n.appendChild(a),n}function Pe(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",qe),document.getElementById("enable-sonarr-webhook").addEventListener("click",je),document.getElementById("enable-radarr-webhook").addEventListener("click",Ge),document.getElementById("test-sonarr-webhook").addEventListener("click",_e),document.getElementById("test-radarr-webhook").addEventListener("click",ze))}function qe(){o.webhookSectionExpanded=!o.webhookSectionExpanded;const e=document.getElementById("webhooks-content"),t=document.getElementById("webhooks-toggle");o.webhookSectionExpanded?e.classList.remove("hidden"):e.classList.add("hidden"),t.classList.toggle("expanded",o.webhookSectionExpanded),o.webhookSectionExpanded&&te()}async function te(){const e=document.getElementById("webhook-loading");e.classList.remove("hidden");try{(await N()).success&&Oe()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.classList.add("hidden")}}function Oe(){const e=document.getElementById("sonarr-status"),t=document.getElementById("enable-sonarr-webhook"),n=document.getElementById("test-sonarr-webhook"),s=document.getElementById("sonarr-triggers"),a=document.getElementById("sonarr-stats");e.textContent=sonarrWebhook.enabled?"● Enabled":"○ Disabled",e.className="status-indicator "+(sonarrWebhook.enabled?"enabled":"disabled"),sonarrWebhook.enabled?(t.classList.add("hidden"),n.classList.remove("hidden"),s.classList.remove("hidden")):(t.classList.remove("hidden"),n.classList.add("hidden"),s.classList.add("hidden")),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")),sonarrWebhook.stats?(a.classList.remove("hidden"),document.getElementById("sonarr-events").textContent=sonarrWebhook.stats.eventsReceived??0,document.getElementById("sonarr-polls").textContent=sonarrWebhook.stats.pollsSkipped??0,document.getElementById("sonarr-last").textContent=O(sonarrWebhook.stats.lastWebhookTimestamp)):a.classList.add("hidden");const l=document.getElementById("radarr-status"),c=document.getElementById("enable-radarr-webhook"),m=document.getElementById("test-radarr-webhook"),u=document.getElementById("radarr-triggers"),i=document.getElementById("radarr-stats");l.textContent=radarrWebhook.enabled?"● Enabled":"○ Disabled",l.className="status-indicator "+(radarrWebhook.enabled?"enabled":"disabled"),radarrWebhook.enabled?(c.classList.add("hidden"),m.classList.remove("hidden"),u.classList.remove("hidden")):(c.classList.remove("hidden"),m.classList.add("hidden"),u.classList.add("hidden")),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")),radarrWebhook.stats?(i.classList.remove("hidden"),document.getElementById("radarr-events").textContent=radarrWebhook.stats.eventsReceived??0,document.getElementById("radarr-polls").textContent=radarrWebhook.stats.pollsSkipped??0,document.getElementById("radarr-last").textContent=O(radarrWebhook.stats.lastWebhookTimestamp)):i.classList.add("hidden")}async function je(){w(!0);try{const e=await be();e.success||(console.error("Failed to enable Sonarr webhook:",e.error),alert("Failed to enable Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Sonarr webhook:",e),alert("Failed to enable Sonarr webhook. Check console for details.")}finally{w(!1)}}async function Ge(){w(!0);try{const e=await ve();e.success||(console.error("Failed to enable Radarr webhook:",e.error),alert("Failed to enable Radarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Radarr webhook:",e),alert("Failed to enable Radarr webhook. Check console for details.")}finally{w(!1)}}async function _e(){w(!0);try{const e=await Ee();e.success?alert("Sonarr webhook test sent successfully!"):(console.error("Failed to test Sonarr webhook:",e.error),alert("Failed to test Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to test Sonarr webhook:",e),alert("Failed to test Sonarr webhook. Check console for details.")}finally{w(!1)}}async function ze(){w(!0);try{const e=await we();e.success?alert("Radarr webhook test sent successfully!"):(console.error("Failed to test Radarr webhook:",e.error),alert("Failed to test Radarr webhook. Check console for details."))}catch(e){console.error("Failed to test Radarr webhook:",e),alert("Failed to test Radarr webhook. Check console for details.")}finally{w(!1)}}function w(e){o.webhookLoading=e,document.getElementById("enable-sonarr-webhook").disabled=e,document.getElementById("enable-radarr-webhook").disabled=e,document.getElementById("test-sonarr-webhook").disabled=e,document.getElementById("test-radarr-webhook").disabled=e;const t=document.getElementById("webhook-loading");e?t.classList.remove("hidden"):t.classList.add("hidden")}async function Je(){const e=document.getElementById("status-panel"),t=document.getElementById("webhooks-section");if(!e.classList.contains("hidden")){e.classList.add("hidden"),t&&t.classList.add("hidden"),o.statusRefreshHandle&&(clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=null);return}e.classList.remove("hidden"),t&&o.isAdmin?(t.classList.remove("hidden"),o.webhookSectionExpanded=!1,document.getElementById("webhooks-content").classList.add("hidden"),document.getElementById("webhooks-toggle").classList.remove("expanded"),await te()):t&&t.classList.add("hidden"),_(),o.statusRefreshHandle&&clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=setInterval(_,de)}function Ke(){document.getElementById("status-panel").classList.add("hidden");const e=document.getElementById("webhooks-section");e&&e.classList.add("hidden"),o.statusRefreshHandle&&(clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=null)}async function _(){var n;const e=document.getElementById("status-panel"),t=document.getElementById("status-content");if(console.log("[Status] panel found:",!!e,"contentDiv found:",!!t,"panel display:",(n=e==null?void 0:e.style)==null?void 0:n.display),!(!e||e.classList.contains("hidden"))){console.log("[Status] Refreshing status panel...");try{const s=await ke();s.success&&(console.log("[Status] Got status data, rendering..."),Xe(s.data,e))}catch(s){console.error("[Status] Error fetching status:",s),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

Failed to load status: '+s.message+"

")}}}function Xe(e,t){var I,L,b,$,H,W,U,P,q;console.log("[Status] renderStatusPanel called with data:",e?"yes":"no","keys:",e?Object.keys(e):"none");const n=e.server,s=Math.floor(n.uptimeSeconds/3600),a=Math.floor(n.uptimeSeconds%3600/60),l=n.uptimeSeconds%60,c=`${s}h ${a}m ${l}s`,m=(e.cache.totalSizeBytes/1024).toFixed(1);let u=` +• Trigger an automatic search for a new release`)){e.disabled=!0,e.textContent="⏳ Working…";try{await qe(t),e.textContent="✓ Done — searching…",e.className="blocklist-search-btn success"}catch(s){console.error("[Blocklist] Error:",s),e.disabled=!1,e.textContent="⛔ Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${s.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger an automatic search"},4e3)}}}function Oe(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const i=document.createElement("div");i.className="download-cover";const u=document.createElement("img");u.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",u.alt=e.movieName||e.seriesName||e.title,u.loading="lazy",i.appendChild(u),t.appendChild(i)}const s=document.createElement("div");s.className="download-info";const o=document.createElement("div");o.className="download-header";const a=document.createElement("span");if(a.className=`download-type ${e.type}`,e.type==="series")a.textContent="📺 Series";else if(e.type==="movie")a.textContent="🎬 Movie";else if(e.type==="torrent"){const i=e.instanceName?` (${e.instanceName})`:"";a.textContent=`📥 Torrent${i}`}else a.textContent=e.type;const r=document.createElement("span");if(r.className=`download-status ${e.status}`,r.textContent=e.status,o.appendChild(a),o.appendChild(r),e.importIssues&&e.importIssues.length>0){const i=document.createElement("span");i.className="import-issue-badge",i.textContent="Import Pending",i.setAttribute("data-tooltip",e.importIssues.join(` +`)),o.appendChild(i)}if((n.isAdmin||e.canBlocklist)&&e.arrQueueId){const i=document.createElement("button");i.className="blocklist-search-btn",i.textContent="⛔ Blocklist & Search",i.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",i.addEventListener("click",()=>Pe(i,e)),o.appendChild(i)}const c=document.createElement("div");c.className="download-header-right";const m=$(e.tagBadges,n.showAll,e.matchedUserTag);c.appendChild(m),o.appendChild(c),e.client&&t.appendChild(ae(e));const d=document.createElement("h3");if(d.className="download-title",d.textContent=e.title,s.appendChild(o),s.appendChild(d),e.seriesName){const i=document.createElement("p");i.className="download-series";const u=K(e);u.hasChildNodes()&&(i.appendChild(u),i.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Series: ${e.seriesName}`,i.appendChild(p),s.appendChild(i);const h=oe(e.episodes);h&&s.appendChild(h)}if(e.movieName){const i=document.createElement("p");i.className="download-movie";const u=K(e);u.hasChildNodes()&&(i.appendChild(u),i.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Movie: ${e.movieName}`,i.appendChild(p),s.appendChild(i)}const l=document.createElement("div");l.className="download-details";const f=B("Size",We(e.size));if(l.appendChild(f),e.progress!==void 0){const i=document.createElement("div");i.className="detail-item progress-item",i.dataset.label="Progress";const u=document.createElement("span");u.className="detail-label",u.textContent="Progress";const p=document.createElement("div");p.className="progress-container";const h=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,b=parseFloat(e.progress)||0,E=document.createElement("div");if(E.className="progress-bar",b>0){const k=document.createElement("div");k.className="progress-segment downloaded",k.style.width=b+"%",E.appendChild(k)}p.appendChild(E);const T=document.createElement("span");if(T.className="progress-text",T.textContent=e.progress+"%",p.appendChild(T),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&y>0&&h>0){const k=document.createElement("span");k.className="missing-text",k.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`,p.appendChild(k)}i.appendChild(u),i.appendChild(p),l.appendChild(i)}if(e.speed&&e.speed>0){const i=B("Speed",ne(e.speed));l.appendChild(i)}if(e.eta){const i=B("ETA",e.eta);l.appendChild(i)}if(e.qbittorrent){if(e.seeds!==void 0){const i=B("Seeds",e.seeds);l.appendChild(i)}if(e.peers!==void 0){const i=B("Peers",e.peers);l.appendChild(i)}if(e.availability!==void 0){const i=B("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&i.classList.add("availability-warning"),l.appendChild(i)}}if(e.completedAt){const i=B("Completed",je(e.completedAt));l.appendChild(i)}if(n.isAdmin&&(e.downloadPath||e.targetPath)){const i=document.createElement("div");if(i.className="download-paths",e.downloadPath){const u=document.createElement("div");u.className="path-item",u.innerHTML='Download: '+Q(e.downloadPath)+"",i.appendChild(u)}if(e.targetPath){const u=document.createElement("div");u.className="path-item",u.innerHTML='Target: '+Q(e.targetPath)+"",i.appendChild(u)}l.appendChild(i)}return s.appendChild(l),t.appendChild(s),t}function B(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const o=document.createElement("span");o.className="detail-label",o.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(o),s.appendChild(a),s}function je(e){return e?new Date(e).toLocaleString():"N/A"}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{n.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(n.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(s){console.error("[Migration] Failed to migrate download client filter:",s)}else try{const s=localStorage.getItem("sofarr-download-clients");n.selectedDownloadClients=s?JSON.parse(s):[]}catch(s){console.error("[Migration] Failed to load download client filter:",s),n.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(n.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");n.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-request-types");t&&(n.selectedRequestTypes=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request types:",t),n.selectedRequestTypes=["movie","tv"]}try{const t=localStorage.getItem("sofarr-request-statuses");t&&(n.selectedRequestStatuses=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request statuses:",t),n.selectedRequestStatuses=[]}try{const t=localStorage.getItem("sofarr-request-sort");t&&(n.requestSortMode=t)}catch(t){console.error("[Storage] Failed to load request sort:",t),n.requestSortMode="requestedDate_desc"}try{const t=localStorage.getItem("sofarr-request-search");t!==null&&(n.requestSearchQuery=t)}catch(t){console.error("[Storage] Failed to load request search:",t),n.requestSearchQuery=""}})();function _e(e){localStorage.setItem("sofarr-history-days",e)}function Ge(e){localStorage.setItem("sofarr-ignore-available",e)}function re(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function ie(){return localStorage.getItem("sofarr-theme")||"light"}function ze(e){localStorage.setItem("sofarr-theme",e)}function Je(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function A(e){localStorage.setItem("sofarr-active-tab",e)}function le(e){localStorage.setItem("sofarr-request-types",JSON.stringify(e))}function ce(e){localStorage.setItem("sofarr-request-statuses",JSON.stringify(e))}function Qe(e){localStorage.setItem("sofarr-request-sort",e)}function Ke(e){localStorage.setItem("sofarr-request-search",e)}function X(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi";const o=document.createElement("a");o.href=e.ombiLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}if(n.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const o=document.createElement("a");o.href=e.arrLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}return t}function Xe(){const e=document.getElementById("history-days"),t=document.getElementById("history-refresh-btn"),s=document.getElementById("ignore-available-toggle");e&&e.addEventListener("change",()=>{const o=parseInt(e.value,10);o>0&&o<=90&&(historyDays=o,_e(o),x(!0))}),t&&t.addEventListener("click",()=>x(!0)),s&&(s.checked=n.ignoreAvailable,s.addEventListener("change",()=>{n.ignoreAvailable=s.checked,Ge(n.ignoreAvailable),ue(n.lastHistoryItems)})),document.addEventListener("historyReload",()=>{x(!0)})}function Ve(){de(),n.historyRefreshHandle=setInterval(()=>x(),we)}function de(){n.historyRefreshHandle&&(clearInterval(n.historyRefreshHandle),n.historyRefreshHandle=null)}function Ye(){n.lastHistoryItems=[],document.getElementById("history-list").innerHTML="",document.getElementById("no-history").classList.add("hidden"),document.getElementById("history-error").classList.add("hidden")}async function x(e=!1){document.getElementById("history-list");const t=document.getElementById("history-loading"),s=document.getElementById("history-error"),o=document.getElementById("no-history");t.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("hidden");try{const a=await Le(e);t.classList.add("hidden"),a.success?(n.lastHistoryItems=a.history,ue(n.lastHistoryItems)):(s.textContent=a.error||"Failed to load history.",s.classList.remove("hidden"))}catch(a){t.classList.add("hidden"),s.textContent="Failed to load history.",s.classList.remove("hidden"),console.error("[History] Load error:",a)}}function ue(e){const t=document.getElementById("history-list"),s=document.getElementById("no-history");t.innerHTML="";const o=n.ignoreAvailable?e.filter(a=>!(a.outcome==="failed"&&a.availableForUpgrade)):e;if(!o.length){s.classList.remove("hidden");return}s.classList.add("hidden"),o.forEach(a=>t.appendChild(Ze(a)))}function Ze(e){const t=document.createElement("div");if(t.className=`history-card ${e.type} ${e.outcome}`,e.coverArt){const l=document.createElement("div");l.className="history-cover";const f=document.createElement("img");f.src="/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt),f.alt=e.movieName||e.seriesName||e.title,f.loading="lazy",l.appendChild(f),t.appendChild(l)}const s=document.createElement("div");s.className="history-info";const o=document.createElement("div");o.className="history-card-header";const a=document.createElement("span");a.className=`history-type-badge ${e.type}`,a.textContent=e.type==="series"?"📺 Series":"🎬 Movie",o.appendChild(a);const r=document.createElement("span");if(r.className=`history-outcome-badge ${e.outcome}`,r.textContent=e.outcome==="imported"?"✓ Imported":"✗ Failed",o.appendChild(r),e.availableForUpgrade){const l=document.createElement("span");l.className="history-upgrade-badge",l.title="A previous version of this item is available. An upgrade download has failed.",l.textContent="⬆ Available",o.appendChild(l)}if(e.instanceName){const l=document.createElement("span");l.className="history-instance-badge",l.textContent=e.instanceName,o.appendChild(l)}const c=$(e.tagBadges,n.showAll,e.matchedUserTag);o.appendChild(c),s.appendChild(o);const m=document.createElement("h3");if(m.className="history-title",m.textContent=e.title,s.appendChild(m),e.seriesName){const l=document.createElement("p");l.className="history-media-name";const f=X(e);f.hasChildNodes()&&(l.appendChild(f),l.appendChild(document.createTextNode(" ")));const i=document.createElement("span");i.textContent="Series: "+e.seriesName,l.appendChild(i),s.appendChild(l);const u=oe(e.episodes);u&&s.appendChild(u)}if(e.movieName){const l=document.createElement("p");l.className="history-media-name";const f=X(e);f.hasChildNodes()&&(l.appendChild(f),l.appendChild(document.createTextNode(" ")));const i=document.createElement("span");i.textContent="Movie: "+e.movieName,l.appendChild(i),s.appendChild(l)}const d=document.createElement("div");if(d.className="history-details",e.completedAt&&d.appendChild(V("Completed",He(e.completedAt))),e.quality&&d.appendChild(V("Quality",e.quality)),e.outcome==="failed"&&e.failureMessage){const l=document.createElement("div");l.className="history-failure-message",l.textContent=e.failureMessage,d.appendChild(l)}return s.appendChild(d),t.appendChild(s),t}function V(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const o=document.createElement("span");o.className="detail-label",o.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(o),s.appendChild(a),s}function H(){me();const e=n.showAll?"?showAll=true":"",t=new EventSource("/api/dashboard/stream"+e);n.sseSource=t;let s=!0;t.onmessage=o=>{try{const a=JSON.parse(o.data);if(n.currentUser=a.user,n.isAdmin=!!a.isAdmin,n.downloads=a.downloads,a.downloadClients){n.downloadClients=a.downloadClients;const r=new CustomEvent("downloadClientsUpdated");document.dispatchEvent(r)}if(a.ombiRequests){n.ombiRequests=a.ombiRequests;const r=new CustomEvent("ombiRequestsUpdated");document.dispatchEvent(r)}a.ombiBaseUrl&&(n.ombiBaseUrl=a.ombiBaseUrl),document.getElementById("currentUser").textContent=n.currentUser||"-",W(),vt(),s&&(s=!1,Et())}catch(a){console.error("[SSE] Failed to parse message:",a)}},t.addEventListener("history-update",o=>{try{const a=JSON.parse(o.data);console.log("[SSE] History update received:",a.type);const r=new CustomEvent("historyReload");document.dispatchEvent(r)}catch(a){console.error("[SSE] Failed to parse history-update message:",a)}}),t.onerror=()=>{console.warn("[SSE] Connection lost, browser will retry...")},console.log("[SSE] Stream connected")}function me(){n.sseSource&&(n.sseSource.close(),n.sseSource=null,console.log("[SSE] Stream closed"))}function et(e){n.showAll=e,H();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}function tt(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",st),document.getElementById("enable-sonarr-webhook").addEventListener("click",ot),document.getElementById("enable-radarr-webhook").addEventListener("click",at),document.getElementById("enable-ombi-webhook").addEventListener("click",lt),document.getElementById("test-sonarr-webhook").addEventListener("click",rt),document.getElementById("test-radarr-webhook").addEventListener("click",it),document.getElementById("test-ombi-webhook").addEventListener("click",ct))}function st(){n.webhookSectionExpanded=!n.webhookSectionExpanded;const e=document.getElementById("webhooks-content"),t=document.getElementById("webhooks-toggle");n.webhookSectionExpanded?e.classList.remove("hidden"):e.classList.add("hidden"),t.classList.toggle("expanded",n.webhookSectionExpanded),n.webhookSectionExpanded&&he()}async function he(){const e=document.getElementById("webhook-loading");e.classList.remove("hidden");try{(await q()).success&&nt()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.classList.add("hidden")}}function nt(){const e=document.getElementById("sonarr-status"),t=document.getElementById("enable-sonarr-webhook"),s=document.getElementById("test-sonarr-webhook"),o=document.getElementById("sonarr-triggers"),a=document.getElementById("sonarr-stats");e.textContent=n.sonarrWebhook.enabled?"● Enabled":"○ Disabled",e.className="status-indicator "+(n.sonarrWebhook.enabled?"enabled":"disabled"),n.sonarrWebhook.enabled?(t.classList.add("hidden"),s.classList.remove("hidden"),o.classList.remove("hidden")):(t.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("hidden")),n.sonarrWebhook.enabled&&(document.getElementById("sonarr-onGrab").textContent=n.sonarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("sonarr-onGrab").className="trigger-value "+(n.sonarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("sonarr-onDownload").textContent=n.sonarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("sonarr-onDownload").className="trigger-value "+(n.sonarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("sonarr-onImport").textContent=n.sonarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("sonarr-onImport").className="trigger-value "+(n.sonarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("sonarr-onUpgrade").textContent=n.sonarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("sonarr-onUpgrade").className="trigger-value "+(n.sonarrWebhook.triggers.onUpgrade?"active":"inactive")),n.sonarrWebhook.stats?(a.classList.remove("hidden"),document.getElementById("sonarr-events").textContent=n.sonarrWebhook.stats.eventsReceived??0,document.getElementById("sonarr-polls").textContent=n.sonarrWebhook.stats.pollsSkipped??0,document.getElementById("sonarr-last").textContent=D(n.sonarrWebhook.stats.lastWebhookTimestamp)):a.classList.add("hidden");const r=document.getElementById("radarr-status"),c=document.getElementById("enable-radarr-webhook"),m=document.getElementById("test-radarr-webhook"),d=document.getElementById("radarr-triggers"),l=document.getElementById("radarr-stats");r.textContent=n.radarrWebhook.enabled?"● Enabled":"○ Disabled",r.className="status-indicator "+(n.radarrWebhook.enabled?"enabled":"disabled"),n.radarrWebhook.enabled?(c.classList.add("hidden"),m.classList.remove("hidden"),d.classList.remove("hidden")):(c.classList.remove("hidden"),m.classList.add("hidden"),d.classList.add("hidden")),n.radarrWebhook.enabled&&(document.getElementById("radarr-onGrab").textContent=n.radarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("radarr-onGrab").className="trigger-value "+(n.radarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("radarr-onDownload").textContent=n.radarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("radarr-onDownload").className="trigger-value "+(n.radarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("radarr-onImport").textContent=n.radarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("radarr-onImport").className="trigger-value "+(n.radarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("radarr-onUpgrade").textContent=n.radarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("radarr-onUpgrade").className="trigger-value "+(n.radarrWebhook.triggers.onUpgrade?"active":"inactive")),n.radarrWebhook.stats?(l.classList.remove("hidden"),document.getElementById("radarr-events").textContent=n.radarrWebhook.stats.eventsReceived??0,document.getElementById("radarr-polls").textContent=n.radarrWebhook.stats.pollsSkipped??0,document.getElementById("radarr-last").textContent=D(n.radarrWebhook.stats.lastWebhookTimestamp)):l.classList.add("hidden");const f=document.getElementById("ombi-status"),i=document.getElementById("enable-ombi-webhook"),u=document.getElementById("test-ombi-webhook"),p=document.getElementById("ombi-triggers"),h=document.getElementById("ombi-stats");f.textContent=n.ombiWebhook.enabled?"● Enabled":"○ Disabled",f.className="status-indicator "+(n.ombiWebhook.enabled?"enabled":"disabled"),n.ombiWebhook.enabled?(i.classList.add("hidden"),u.classList.remove("hidden"),p.classList.remove("hidden")):(i.classList.remove("hidden"),u.classList.add("hidden"),p.classList.add("hidden")),n.ombiWebhook.enabled&&(document.getElementById("ombi-requestAvailable").textContent=n.ombiWebhook.triggers.requestAvailable?"✓":"✗",document.getElementById("ombi-requestAvailable").className="trigger-value "+(n.ombiWebhook.triggers.requestAvailable?"active":"inactive"),document.getElementById("ombi-requestApproved").textContent=n.ombiWebhook.triggers.requestApproved?"✓":"✗",document.getElementById("ombi-requestApproved").className="trigger-value "+(n.ombiWebhook.triggers.requestApproved?"active":"inactive"),document.getElementById("ombi-requestDeclined").textContent=n.ombiWebhook.triggers.requestDeclined?"✓":"✗",document.getElementById("ombi-requestDeclined").className="trigger-value "+(n.ombiWebhook.triggers.requestDeclined?"active":"inactive"),document.getElementById("ombi-requestPending").textContent=n.ombiWebhook.triggers.requestPending?"✓":"✗",document.getElementById("ombi-requestPending").className="trigger-value "+(n.ombiWebhook.triggers.requestPending?"active":"inactive"),document.getElementById("ombi-requestProcessing").textContent=n.ombiWebhook.triggers.requestProcessing?"✓":"✗",document.getElementById("ombi-requestProcessing").className="trigger-value "+(n.ombiWebhook.triggers.requestProcessing?"active":"inactive")),n.ombiWebhook.stats?(h.classList.remove("hidden"),document.getElementById("ombi-events").textContent=n.ombiWebhook.stats.eventsReceived??0,document.getElementById("ombi-polls").textContent=n.ombiWebhook.stats.pollsSkipped??0,document.getElementById("ombi-last").textContent=D(n.ombiWebhook.stats.lastWebhookTimestamp)):h.classList.add("hidden")}async function ot(){v(!0);try{const e=await Ne();e.success||(console.error("Failed to enable Sonarr webhook:",e.error),alert("Failed to enable Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Sonarr webhook:",e),alert("Failed to enable Sonarr webhook. Check console for details.")}finally{v(!1)}}async function at(){v(!0);try{const e=await Re();e.success||(console.error("Failed to enable Radarr webhook:",e.error),alert("Failed to enable Radarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Radarr webhook:",e),alert("Failed to enable Radarr webhook. Check console for details.")}finally{v(!1)}}async function rt(){v(!0);try{const e=await De();e.success?alert("Sonarr webhook test sent successfully!"):(console.error("Failed to test Sonarr webhook:",e.error),alert("Failed to test Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to test Sonarr webhook:",e),alert("Failed to test Sonarr webhook. Check console for details.")}finally{v(!1)}}async function it(){v(!0);try{const e=await Ae();e.success?alert("Radarr webhook test sent successfully!"):(console.error("Failed to test Radarr webhook:",e.error),alert("Failed to test Radarr webhook. Check console for details."))}catch(e){console.error("Failed to test Radarr webhook:",e),alert("Failed to test Radarr webhook. Check console for details.")}finally{v(!1)}}async function lt(){v(!0);try{const e=await Fe();e.success||(console.error("Failed to enable Ombi webhook:",e.error),alert("Failed to enable Ombi webhook. Check console for details."))}catch(e){console.error("Failed to enable Ombi webhook:",e),alert("Failed to enable Ombi webhook. Check console for details.")}finally{v(!1)}}async function ct(){v(!0);try{const e=await Me();e.success?alert("Ombi webhook test sent successfully!"):(console.error("Failed to test Ombi webhook:",e.error),alert("Failed to test Ombi webhook. Check console for details."))}catch(e){console.error("Failed to test Ombi webhook:",e),alert("Failed to test Ombi webhook. Check console for details.")}finally{v(!1)}}function v(e){n.webhookLoading=e,document.getElementById("enable-sonarr-webhook").disabled=e,document.getElementById("enable-radarr-webhook").disabled=e,document.getElementById("enable-ombi-webhook").disabled=e,document.getElementById("test-sonarr-webhook").disabled=e,document.getElementById("test-radarr-webhook").disabled=e,document.getElementById("test-ombi-webhook").disabled=e;const t=document.getElementById("webhook-loading");e?t.classList.remove("hidden"):t.classList.add("hidden")}async function dt(){const e=document.getElementById("status-panel"),t=document.getElementById("webhooks-section");if(!e.classList.contains("hidden")){e.classList.add("hidden"),t&&t.classList.add("hidden"),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null);return}e.classList.remove("hidden"),t&&n.isAdmin?(t.classList.remove("hidden"),n.webhookSectionExpanded=!1,document.getElementById("webhooks-content").classList.add("hidden"),document.getElementById("webhooks-toggle").classList.remove("expanded"),await he()):t&&t.classList.add("hidden"),Y(),n.statusRefreshHandle&&clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=setInterval(Y,Se)}function ut(){document.getElementById("status-panel").classList.add("hidden");const e=document.getElementById("webhooks-section");e&&e.classList.add("hidden"),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null)}async function Y(){var s;const e=document.getElementById("status-panel"),t=document.getElementById("status-content");if(console.log("[Status] panel found:",!!e,"contentDiv found:",!!t,"panel display:",(s=e==null?void 0:e.style)==null?void 0:s.display),!(!e||e.classList.contains("hidden"))){console.log("[Status] Refreshing status panel...");try{const o=await $e();o.success?(console.log("[Status] Got status data, rendering..."),mt(o.data,e)):(console.error("[Status] API returned error:",o.error),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

Failed to load status: '+o.error+"

"))}catch(o){console.error("[Status] Error fetching status:",o),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

Failed to load status: '+o.message+"

")}}}function mt(e,t){var E,T,k,O,j,_,G,z,J;console.log("[Status] renderStatusPanel called with data:",e?"yes":"no","keys:",e?Object.keys(e):"none");const s=e.server,o=Math.floor(s.uptimeSeconds/3600),a=Math.floor(s.uptimeSeconds%3600/60),r=s.uptimeSeconds%60,c=`${o}h ${a}m ${r}s`,m=(e.cache.totalSizeBytes/1024).toFixed(1);let d=`

Server Status

@@ -14,29 +14,29 @@ This will:
Server
Uptime${c}
-
Node${D(n.nodeVersion)}
-
Memory (RSS)${n.memoryUsageMB} MB
-
Heap${n.heapUsedMB} / ${n.heapTotalMB} MB
+
Node${N(s.nodeVersion)}
+
Memory (RSS)${s.memoryUsageMB} MB
+
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
-
Data Refresh
`;const i=e.polling.intervalMs,r=(e.clients||[]).filter(h=>h.type==="sse");e.polling.enabled?u+=`
Background poll${i/1e3}s
`:u+='
Background pollDisabled
';const d=r.length>0?'SSE push':e.polling.enabled?"Background":"On-demand (idle)";u+=`
Delivery mode${d}
`,u+=`
SSE clients${r.length}
`;for(const h of r){const k=Math.round((Date.now()-h.connectedAt)/1e3);u+=`
${D(h.user)}connected ${k}s ago
`}if(u+="
",o.isAdmin&&e.webhooks){const h=e.webhooks,k=(I=h.sonarr)!=null&&I.enabled?"●":"○",E=(L=h.radarr)!=null&&L.enabled?"●":"○",S=((b=h.sonarr)==null?void 0:b.eventsReceived)||0,oe=(($=h.radarr)==null?void 0:$.eventsReceived)||0,re=((H=h.sonarr)==null?void 0:H.pollsSkipped)||0,ie=((W=h.radarr)==null?void 0:W.pollsSkipped)||0;u+=` +
Data Refresh
`;const l=e.polling.intervalMs,i=(e.clients||[]).filter(g=>g.type==="sse");e.polling.enabled?d+=`
Background poll${l/1e3}s
`:d+='
Background pollDisabled
';const u=i.length>0?'SSE push':e.polling.enabled?"Background":"On-demand (idle)";d+=`
Delivery mode${u}
`,d+=`
SSE clients${i.length}
`;for(const g of i){const C=Math.round((Date.now()-g.connectedAt)/1e3);d+=`
${N(g.user)}connected ${C}s ago
`}if(d+="
",n.isAdmin&&e.webhooks){const g=e.webhooks,C=(E=g.sonarr)!=null&&E.enabled?"●":"○",w=(T=g.radarr)!=null&&T.enabled?"●":"○",I=((k=g.sonarr)==null?void 0:k.eventsReceived)||0,ye=((O=g.radarr)==null?void 0:O.eventsReceived)||0,ve=((j=g.sonarr)==null?void 0:j.pollsSkipped)||0,Ee=((_=g.radarr)==null?void 0:_.pollsSkipped)||0;d+=`
Webhooks
-
Sonarr${k} ${(U=h.sonarr)!=null&&U.enabled?"Enabled":"Disabled"}
-
Radarr${E} ${(P=h.radarr)!=null&&P.enabled?"Enabled":"Disabled"}
-
EventsS:${S} R:${oe}
-
Polls skippedS:${re} R:${ie}
-
`}const p=e.polling.lastPoll;if(p){const h=Math.round((Date.now()-new Date(p.timestamp).getTime())/1e3);u+=` +
Sonarr${C} ${(G=g.sonarr)!=null&&G.enabled?"Enabled":"Disabled"}
+
Radarr${w} ${(z=g.radarr)!=null&&z.enabled?"Enabled":"Disabled"}
+
EventsS:${I} R:${ye}
+
Polls skippedS:${ve} R:${Ee}
+ `}const p=e.polling.lastPoll;if(p){const g=Math.round((Date.now()-new Date(p.timestamp).getTime())/1e3);d+=`
-
Last Poll (${p.totalMs}ms total, ${h}s ago)
-
`;const k=p.tasks.reduce((E,S)=>Math.max(E,S.ms),1);for(const E of p.tasks){const S=Math.max(2,E.ms/k*100);u+=` +
Last Poll (${p.totalMs}ms total, ${g}s ago)
+
`;const C=p.tasks.reduce((w,I)=>Math.max(w,I.ms),1);for(const w of p.tasks){const I=Math.max(2,w.ms/C*100);d+=`
- ${D(E.label)} -
- ${E.ms}ms -
`}u+="
"}u+=` + ${N(w.label)} +
+ ${w.ms}ms +
`}d+=""}d+=`
Cache (${e.cache.entryCount} entries, ${m} KB)
- `;for(const h of e.cache.entries){const k=h.sizeBytes>1024?(h.sizeBytes/1024).toFixed(1)+" KB":h.sizeBytes+" B",E=h.expired?'expired':(h.ttlRemainingMs/1e3).toFixed(0)+"s",S=h.itemCount!==null?h.itemCount:"—";u+=``}u+="
KeyItemsSizeTTL
${D(h.key)}${S}${k}${E}
";const g=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!g,"panel children:",(q=y==null?void 0:y.children)==null?void 0:q.length,"HTML length:",u.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),g?(g.innerHTML=u,console.log("[Status] HTML rendered, contentDiv innerHTML length:",g.innerHTML.length)):console.error("[Status] contentDiv not found!");const B=document.getElementById("status-close-btn");B&&B.addEventListener("click",Ke),t.querySelectorAll(".timing-bar[data-w]").forEach(h=>{h.style.width=h.dataset.w+"%"})}function D(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Qe(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function Ve(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function M(e){return new Promise(t=>{const n=Date.now()-(e||0),s=Math.max(0,le-n);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},s)})}async function Ye(){const e=Date.now();try{(await ue()).authenticated?(ne(),se(),F(),await M(e)):(await M(e),A())}catch(t){console.error("Authentication check failed:",t),await M(e),A()}}async function Ze(e){e.preventDefault();const t=document.getElementById("username").value,n=document.getElementById("password").value,s=document.getElementById("remember-me").checked;try{const a=await me(t,n,s);if(a.success){await Qe(),Ve(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),ne(),se();const l=Date.now();F(),await M(l)}else z(a.error||"Login failed")}catch(a){z("Login failed. Please try again."),console.error(a)}}async function et(){try{V(),Z(),statusRefreshHandle&&(clearInterval(statusRefreshHandle),statusRefreshHandle.value=null),await he(),currentUser=null,We(),A()}catch(e){console.error("Logout failed:",e)}}function A(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),tt()}function ne(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=o.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const n=document.getElementById("admin-controls");o.isAdmin?n.classList.remove("hidden"):n.classList.add("hidden");const s=document.getElementById("history-days");s&&(s.value=o.historyDays),He()}function z(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function tt(){document.getElementById("login-error").classList.add("hidden")}function nt(){document.getElementById("error-message").classList.add("hidden")}function se(){document.getElementById("loading").classList.remove("hidden")}function st(){document.getElementById("loading").classList.add("hidden")}function at(){const e=document.getElementById("download-client-filter-btn"),t=document.getElementById("download-client-filter-dropdown"),n=document.getElementById("download-client-filter-close");!e||!t||(e.addEventListener("click",s=>{s.stopPropagation(),t.classList.toggle("open")}),n.addEventListener("click",()=>{t.classList.remove("open")}),document.addEventListener("click",s=>{!t.contains(s.target)&&s.target!==e&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",J),J())}function J(){const e=document.getElementById("download-client-filter-list");e&&(e.innerHTML="",o.downloadClients.forEach((t,n)=>{const s=document.createElement("div");s.className="filter-item",s.dataset.index=n;const a=document.createElement("input");a.type="checkbox",a.id=`client-${n}`,a.checked=o.selectedDownloadClients.includes(n),a.addEventListener("change",()=>ot(n));const l=document.createElement("label");l.htmlFor=`client-${n}`,l.textContent=t.name||`${t.type} (${t.id})`,s.appendChild(a),s.appendChild(l),e.appendChild(s)}),ae())}function ot(e){const t=o.selectedDownloadClients.indexOf(e);t>-1?o.selectedDownloadClients.splice(t,1):o.selectedDownloadClients.push(e),Ae(o.selectedDownloadClients),ae(),Q()}function ae(){const e=document.getElementById("download-client-filter-count");e&&(o.selectedDownloadClients.length===0?e.textContent="All":e.textContent=o.selectedDownloadClients.length)}(function(){const t=Y();t&&document.documentElement.setAttribute("data-theme",t)})();function rt(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const n=Y()==="dark"?"light":"dark";it(n)})}function it(e){document.documentElement.setAttribute("data-theme",e),Re(e)}function lt(){const e=document.getElementById("downloads-tab"),t=document.getElementById("history-tab");if(document.getElementById("downloads-section"),document.getElementById("history-section"),!e||!t)return;const n=Fe();T(n==="history"?"history":"downloads"),e.addEventListener("click",()=>T("downloads")),t.addEventListener("click",()=>T("history"))}function T(e){const t=document.getElementById("downloads-tab"),n=document.getElementById("history-tab"),s=document.getElementById("tab-downloads"),a=document.getElementById("tab-history");e==="downloads"?(t.classList.add("active"),n.classList.remove("active"),s.classList.remove("hidden"),a.classList.add("hidden"),j("downloads")):e==="history"&&(n.classList.add("active"),t.classList.remove("active"),a.classList.remove("hidden"),s.classList.add("hidden"),j("history"),x())}function ct(){T("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",Ze);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",et);const n=document.getElementById("show-all-toggle");n&&n.addEventListener("change",l=>Ne(l.target.checked));const s=document.getElementById("status-toggle");s&&s.addEventListener("click",Je);const a=document.getElementById("home-btn");a&&a.addEventListener("click",ct),rt(),lt(),at(),$e(),Pe(),ge().then(l=>{const c=document.getElementById("app-version");c&&l&&(c.textContent="v"+l)}),Ye()}); + `;for(const g of e.cache.entries){const C=g.sizeBytes>1024?(g.sizeBytes/1024).toFixed(1)+" KB":g.sizeBytes+" B",w=g.expired?'expired':(g.ttlRemainingMs/1e3).toFixed(0)+"s",I=g.itemCount!==null?g.itemCount:"—";d+=`${N(g.key)}${I}${C}${w}`}d+="";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(J=y==null?void 0:y.children)==null?void 0:J.length,"HTML length:",d.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=d,console.log("[Status] HTML rendered, contentDiv innerHTML length:",h.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",ut),t.querySelectorAll(".timing-bar[data-w]").forEach(g=>{g.style.width=g.dataset.w+"%"})}function N(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function ht(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function pt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function R(e){return new Promise(t=>{const s=Date.now()-(e||0),o=Math.max(0,ke-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},o)})}async function ft(){const e=Date.now();try{(await Ce()).authenticated?(pe(),fe(),H(),await R(e)):(await R(e),F())}catch(t){console.error("Authentication check failed:",t),await R(e),F()}}async function gt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,o=document.getElementById("remember-me").checked;try{const a=await Ie(t,s,o);if(a.success){await ht(),pt(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),pe(),fe();const r=Date.now();H(),await R(r)}else Z(a.error||"Login failed")}catch(a){Z("Login failed. Please try again."),console.error(a)}}async function bt(){try{me(),de(),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null),await Be(),n.currentUser=null,Ye(),F()}catch(e){console.error("Logout failed:",e)}}function F(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),yt()}function pe(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=n.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");n.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const o=document.getElementById("history-days");o&&(o.value=n.historyDays),Ve()}function Z(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function yt(){document.getElementById("login-error").classList.add("hidden")}function vt(){document.getElementById("error-message").classList.add("hidden")}function fe(){document.getElementById("loading").classList.remove("hidden")}function Et(){document.getElementById("loading").classList.add("hidden")}function kt(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),o=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ee(!0)),o&&o.addEventListener("click",()=>ee(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",M),M())}function M(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",n.downloadClients.forEach((t,s)=>{const o=document.createElement("div");o.className="filter-item",o.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.id=`client-${s}`,a.checked=n.selectedDownloadClients.includes(s),a.addEventListener("change",()=>wt(s));const r=document.createElement("label");r.htmlFor=`client-${s}`,r.textContent=t.name||`${t.type} (${t.id})`,o.appendChild(a),o.appendChild(r),e.appendChild(o)}),ge())}function wt(e){const t=n.selectedDownloadClients.indexOf(e);t>-1?n.selectedDownloadClients.splice(t,1):n.selectedDownloadClients.push(e),re(n.selectedDownloadClients),ge(),W()}function ee(e){e?n.selectedDownloadClients=n.downloadClients.map((t,s)=>s):n.selectedDownloadClients=[],re(n.selectedDownloadClients),M(),W()}function ge(){const e=document.getElementById("download-client-selected-text");if(e)if(n.selectedDownloadClients.length===0)e.textContent="All clients";else if(n.selectedDownloadClients.length===n.downloadClients.length)e.textContent="All clients";else{const t=n.selectedDownloadClients.map(s=>{var o,a;return((o=n.downloadClients[s])==null?void 0:o.name)||((a=n.downloadClients[s])==null?void 0:a.type)||""}).filter(Boolean);t.length===1?e.textContent=t[0]:e.textContent=`${n.selectedDownloadClients.length} clients`}}function be(e){return e?e.available?"available":e.denied?"denied":e.approved?"approved":e.requested?"pending":"unknown":"unknown"}function St(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return s.includes("all")?e:e.filter(o=>s.includes(o.mediaType))}function Ct(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return e.filter(o=>s.includes(be(o)))}function It(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(o=>(o.title||"").toLowerCase().includes(s))}function Bt(e,t){const s=[...e];switch(t){case"requestedDate_asc":return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0,c=a.requestedDate?new Date(a.requestedDate).getTime():0;return r-c});case"title_asc":return s.sort((o,a)=>(o.title||"").localeCompare(a.title||""));case"title_desc":return s.sort((o,a)=>(a.title||"").localeCompare(o.title||""));case"requestedDate_desc":default:return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0;return(a.requestedDate?new Date(a.requestedDate).getTime():0)-r})}}function Lt(e,{types:t,statuses:s,sort:o,search:a}={}){let r=[...e];return r=St(r,t),r=Ct(r,s),r=It(r,a),r=Bt(r,o),r}function qt(e){return e?e.requestedUser&&typeof e.requestedUser=="object"?e.requestedUser.alias||e.requestedUser.userAlias||e.requestedUser.userName||e.requestedUser.normalizedUserName||e.requestedByAlias||"":e.requestedUser||e.requestedByAlias||"":""}function S(){const e=document.getElementById("requests-list"),t=document.getElementById("no-requests");if(!e)return;const s=n.ombiRequests||{movie:[],tv:[]},o=[...s.movie.map(r=>({...r,mediaType:"movie"})),...s.tv.map(r=>({...r,mediaType:"tv"}))],a=Lt(o,{types:n.selectedRequestTypes,statuses:n.selectedRequestStatuses,sort:n.requestSortMode,search:n.requestSearchQuery});if(e.innerHTML="",a.length===0){if(t){t.style.display="block";const r=t.querySelector("p");if(r){const c=o.length>0;r.textContent=c?"No requests match your filters.":"No requests found."}}return}t&&(t.style.display="none"),a.forEach(r=>{const c=Tt(r);e.appendChild(c)})}function Tt(e){if(!e){const l=document.createElement("div");return l.className="request-card",l.textContent="Invalid request data",l}const t=document.createElement("div");t.className="request-card";const s=document.createElement("span");s.className=`request-type-icon ${e.mediaType||""}`,s.textContent=e.mediaType==="movie"?"🎬":"📺";const o=document.createElement("div");o.className="request-content";const a=document.createElement("div");a.className="request-title",a.textContent=e.title||"Unknown Title";const r=document.createElement("div");r.className="request-meta";const c=xt(e);if(r.appendChild(c),e.year){const l=document.createElement("span");l.className="request-year",l.textContent=e.year,r.appendChild(l)}const m=qt(e);if(m){const l=document.createElement("span");l.className="request-user",l.textContent=`Requested by: ${m}`,r.appendChild(l)}if(e.quality){const l=document.createElement("span");l.className="request-quality",l.textContent=e.quality,r.appendChild(l)}o.appendChild(a),o.appendChild(r);const d=document.createElement("div");if(d.className="request-actions",n.ombiBaseUrl&&e.theMovieDbId){const l=document.createElement("a");l.className="request-link ombi-link",l.href=`${n.ombiBaseUrl}/details/${e.mediaType||"movie"}/${e.theMovieDbId}`,l.target="_blank",l.title="View in Ombi";const f=document.createElement("img");f.src="/images/ombi.svg",f.alt="Ombi",f.className="request-icon",l.appendChild(f),d.appendChild(l)}return t.appendChild(s),t.appendChild(o),t.appendChild(d),t}function xt(e){const t=document.createElement("span");t.className="request-status-badge";const s=be(e),o={available:"Available",denied:`Denied: ${e.deniedReason||"No reason"}`,approved:"Approved",pending:"Pending",unknown:"Unknown"};return t.classList.add(s),t.textContent=o[s]||"Unknown",t}function Nt(){const e=document.getElementById("request-type-filter-btn"),t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-select-all"),o=document.getElementById("request-type-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>te(!0)),o==null||o.addEventListener("click",()=>te(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Rt(c,r.checked)})}),U()}function te(e){const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestTypes=e?o:[],le(n.selectedRequestTypes),U(),S()}function Rt(e,t){const s=n.selectedRequestTypes.indexOf(e);t&&s===-1?n.selectedRequestTypes.push(e):!t&&s>-1&&n.selectedRequestTypes.splice(s,1),le(n.selectedRequestTypes),U(),S()}function U(){const e=document.getElementById("request-type-selected-text");if(!e)return;const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestTypes.includes(a)}),n.selectedRequestTypes.length===0||n.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=n.selectedRequestTypes.length}function Dt(){const e=document.getElementById("request-status-filter-btn"),t=document.getElementById("request-status-filter-dropdown"),s=document.getElementById("request-status-select-all"),o=document.getElementById("request-status-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>se(!0)),o==null||o.addEventListener("click",()=>se(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;At(c,r.checked)})}),P()}function se(e){const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestStatuses=e?o:[],ce(n.selectedRequestStatuses),P(),S()}function At(e,t){const s=n.selectedRequestStatuses.indexOf(e);t&&s===-1?n.selectedRequestStatuses.push(e):!t&&s>-1&&n.selectedRequestStatuses.splice(s,1),ce(n.selectedRequestStatuses),P(),S()}function P(){const e=document.getElementById("request-status-selected-text");if(!e)return;const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestStatuses.includes(a)}),n.selectedRequestStatuses.length===0||n.selectedRequestStatuses.length===s.length?e.textContent="All":e.textContent=n.selectedRequestStatuses.length}function Ft(){const e=document.getElementById("request-sort-select");e&&(e.value=n.requestSortMode,e.addEventListener("change",t=>{n.requestSortMode=t.target.value,Qe(n.requestSortMode),S()}))}function Mt(){const e=document.getElementById("request-search-input");if(!e)return;e.value=n.requestSearchQuery;let t;e.addEventListener("input",s=>{clearTimeout(t),t=setTimeout(()=>{n.requestSearchQuery=s.target.value,Ke(n.requestSearchQuery),S()},200)})}function $t(){document.addEventListener("click",e=>{const t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-filter-btn"),o=document.getElementById("request-status-filter-dropdown"),a=document.getElementById("request-status-filter-btn");t&&!t.contains(e.target)&&e.target!==s&&!(s!=null&&s.contains(e.target))&&t.classList.remove("open"),o&&!o.contains(e.target)&&e.target!==a&&!(a!=null&&a.contains(e.target))&&o.classList.remove("open")})}function Wt(){Nt(),Dt(),Ft(),Mt(),$t(),document.addEventListener("ombiRequestsUpdated",()=>{S()})}(function(){const t=ie();t&&document.documentElement.setAttribute("data-theme",t)})();function Ht(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const s=ie()==="dark"?"light":"dark";Ut(s)})}function Ut(e){document.documentElement.setAttribute("data-theme",e),ze(e)}function Pt(){const e=document.querySelector('[data-tab="downloads"]'),t=document.querySelector('[data-tab="requests"]'),s=document.querySelector('[data-tab="history"]');if(!e||!s)return;const o=Je();L(o==="requests"?"requests":o==="history"?"history":"downloads"),e.addEventListener("click",()=>L("downloads")),t&&t.addEventListener("click",()=>L("requests")),s.addEventListener("click",()=>L("history"))}function L(e){const t=document.querySelector('[data-tab="downloads"]'),s=document.querySelector('[data-tab="requests"]'),o=document.querySelector('[data-tab="history"]'),a=document.getElementById("tab-downloads"),r=document.getElementById("tab-requests"),c=document.getElementById("tab-history");t&&t.classList.remove("active"),s&&s.classList.remove("active"),o&&o.classList.remove("active"),a&&a.classList.add("hidden"),r&&r.classList.add("hidden"),c&&c.classList.add("hidden"),e==="downloads"?(t&&t.classList.add("active"),a&&a.classList.remove("hidden"),A("downloads")):e==="requests"?(s&&s.classList.add("active"),r&&r.classList.remove("hidden"),A("requests"),S()):e==="history"&&(o&&o.classList.add("active"),c&&c.classList.remove("hidden"),A("history"),x())}function Ot(){L("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",gt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",bt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>et(r.target.checked));const o=document.getElementById("status-btn");o&&o.addEventListener("click",dt);const a=document.getElementById("home-btn");a&&a.addEventListener("click",Ot),Ht(),Pt(),kt(),Wt(),Xe(),tt(),Te().then(r=>{const c=document.getElementById("app-version");c&&r&&(c.textContent="v"+r)}),ft()}); diff --git a/public/index.html b/public/index.html index 7356847..8908e42 100644 --- a/public/index.html +++ b/public/index.html @@ -129,6 +129,31 @@ + + +
+

Ombi

+
+ ○ Disabled + + +
+ + +
@@ -139,6 +164,7 @@
+
@@ -172,6 +198,92 @@
+ +