From d3d085d614372e78b5cb7ff966a8490ad62a6a69 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 22 May 2026 12:31:31 +0100 Subject: [PATCH] feat: Add Ombi request filtering and search - Add request filters UI (type, status, sort, search) - Implement dual-layer filtering (server + client) - Add ombiFilters utility for consistent filtering logic - Persist filter preferences in localStorage - Add SSE support for real-time Ombi request updates - Add webhook endpoints for Ombi integration - Update OpenAPI spec for new endpoints - Add unit tests for filter logic and UI - Add integration tests for Ombi routes --- ARCHITECTURE.md | 88 +++++++- client/src/main.js | 2 + client/src/state.js | 8 +- client/src/ui/requestFilters.js | 227 ++++++++++++++++++++ client/src/ui/requests.js | 77 ++++--- client/src/ui/tabs.js | 2 - client/src/utils/ombiFilters.js | 107 ++++++++++ client/src/utils/storage.js | 51 +++++ public/app.js | 40 ++-- public/index.html | 76 ++++++- public/style.css | 180 ++++++++++++++++ server/openapi.yaml | 52 +++++ server/routes/ombi.js | 100 +++++++-- server/utils/ombiFilters.js | 120 +++++++++++ tests/frontend/ui/requestFilters.test.js | 253 +++++++++++++++++++++++ tests/integration/ombi.test.js | 160 ++++++++++++++ tests/unit/ombiFilters.test.js | 235 +++++++++++++++++++++ 17 files changed, 1695 insertions(+), 83 deletions(-) create mode 100644 client/src/ui/requestFilters.js create mode 100644 client/src/utils/ombiFilters.js create mode 100644 server/utils/ombiFilters.js create mode 100644 tests/frontend/ui/requestFilters.test.js create mode 100644 tests/unit/ombiFilters.test.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6558ab6..cb44f42 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 @@ -62,6 +63,7 @@ flowchart TB 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"] + 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 ``` @@ -130,6 +135,7 @@ Express Server (:3001) ├── /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 ├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API └── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy @@ -300,6 +306,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 @@ -539,7 +556,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 +653,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 +750,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/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/state.js b/client/src/state.js index 6895bdb..b4491dc 100644 --- a/client/src/state.js +++ b/client/src/state.js @@ -31,7 +31,13 @@ export const state = { 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 }, ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null }, - webhookMetrics: null + webhookMetrics: null, + + // Request filter state + selectedRequestTypes: ['movie', 'tv'], + selectedRequestStatuses: [], + requestSortMode: 'requestedDate_desc', + requestSearchQuery: '' }; // Constants diff --git a/client/src/ui/requestFilters.js b/client/src/ui/requestFilters.js new file mode 100644 index 0000000..1e0dc88 --- /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 = 'None'; + } 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 index c4a52d4..86fdcf3 100644 --- a/client/src/ui/requests.js +++ b/client/src/ui/requests.js @@ -2,12 +2,15 @@ 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 */ @@ -38,16 +41,34 @@ export function renderRequests() { ...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 (allRequests.length === 0) { - if (noRequests) noRequests.style.display = 'block'; + 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'; - allRequests.forEach(request => { + filtered.forEach(request => { const card = createRequestCard(request); requestsList.appendChild(card); }); @@ -102,7 +123,7 @@ function createRequestCard(request) { const actions = document.createElement('div'); actions.className = 'request-actions'; - if (request.theMovieDbId) { + if (state.ombiBaseUrl && request.theMovieDbId) { const ombiLink = document.createElement('a'); ombiLink.className = 'request-link ombi-link'; ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`; @@ -129,46 +150,18 @@ function createStatusBadge(request) { const badge = document.createElement('span'); badge.className = 'request-status-badge'; - let status = 'unknown'; - let text = 'Unknown'; - - if (request.available) { - status = 'available'; - text = 'Available'; - } else if (request.approved) { - status = 'approved'; - text = 'Approved'; - } else if (request.denied) { - status = 'denied'; - text = `Denied: ${request.deniedReason || 'No reason'}`; - } else if (request.requested) { - status = 'pending'; - text = 'Pending'; - } + 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 = text; + badge.textContent = statusTexts[status] || 'Unknown'; return badge; } -export function setupRequestsTab() { - // Listen for SSE updates - if (state.sseSource) { - state.sseSource.addEventListener('message', (event) => { - const data = JSON.parse(event.data); - if (data.ombiRequests) { - state.ombiRequests = data.ombiRequests; - renderRequests(); - } - }); - } - - // Also listen for custom event triggered from sse.js - document.addEventListener('ombiRequestsUpdated', () => { - renderRequests(); - }); - - // Initial render - renderRequests(); -} diff --git a/client/src/ui/tabs.js b/client/src/ui/tabs.js index aa62125..934845e 100644 --- a/client/src/ui/tabs.js +++ b/client/src/ui/tabs.js @@ -2,7 +2,6 @@ import { getActiveTab, saveActiveTab } from '../utils/storage.js'; import { loadHistory } from './history.js'; -import { setupRequestsTab } from './requests.js'; export function initTabs() { const downloadsTab = document.querySelector('[data-tab="downloads"]'); @@ -55,7 +54,6 @@ export function activateTab(tab) { if (requestsSection) requestsSection.classList.remove('hidden'); saveActiveTab('requests'); setupRequestsTab(); - } else if (tab === 'history') { if (historyTab) historyTab.classList.add('active'); if (historySection) historySection.classList.remove('hidden'); saveActiveTab('history'); 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/public/app.js b/public/app.js index 116c5c6..e5733ef 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:""},ve=1200,Ee=5*60*1e3,ke=5e3;async function Se(){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 we(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 Ce(){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 Ie(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 Be(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 Le(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function qe(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function L(){var e,t;try{const s=qe();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 Te(){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 L(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function xe(){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 L(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function Ne(){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 L(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function Re(){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 L(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function De(){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 L(),{success:!0}}catch(e){return console.error("Failed to enable Ombi webhook:",e),{success:!1,error:e.message}}}async function Ae(){try{if(!(await fetch("/api/ombi/webhook/test",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Test failed");return await L(),{success:!0}}catch(e){return console.error("Failed to test Ombi webhook:",e),{success:!1,error:e.message}}}async function Fe(){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 Me(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 te(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 M(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 ne(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 J(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 oe(){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)We(m,r);else{const d=Ue(r);e.appendChild(d)}}),o.forEach((r,c)=>{a.has(c)||r.remove()})}function We(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=M(t.tagBadges,n.showAll,t.matchedUserTag);i.appendChild(u),c.appendChild(i)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(ne(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=te(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 He(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 Be(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 Ue(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",()=>He(i,e)),o.appendChild(i)}const c=document.createElement("div");c.className="download-header-right";const m=M(e.tagBadges,n.showAll,e.matchedUserTag);c.appendChild(m),o.appendChild(c),e.client&&t.appendChild(ne(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=J(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=se(e.episodes);h&&s.appendChild(h)}if(e.movieName){const i=document.createElement("p");i.className="download-movie";const u=J(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=I("Size",Me(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=I("Speed",te(e.speed));l.appendChild(i)}if(e.eta){const i=I("ETA",e.eta);l.appendChild(i)}if(e.qbittorrent){if(e.seeds!==void 0){const i=I("Seeds",e.seeds);l.appendChild(i)}if(e.peers!==void 0){const i=I("Peers",e.peers);l.appendChild(i)}if(e.availability!==void 0){const i=I("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&i.classList.add("availability-warning"),l.appendChild(i)}}if(e.completedAt){const i=I("Completed",Pe(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: '+z(e.downloadPath)+"",i.appendChild(u)}if(e.targetPath){const u=document.createElement("div");u.className="path-item",u.innerHTML='Target: '+z(e.targetPath)+"",i.appendChild(u)}l.appendChild(i)}return s.appendChild(l),t.appendChild(s),t}function I(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 Pe(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 Oe(e){localStorage.setItem("sofarr-history-days",e)}function je(e){localStorage.setItem("sofarr-ignore-available",e)}function _e(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function ae(){return localStorage.getItem("sofarr-theme")||"light"}function Ge(e){localStorage.setItem("sofarr-theme",e)}function ze(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function A(e){localStorage.setItem("sofarr-active-tab",e)}function re(e){localStorage.setItem("sofarr-request-types",JSON.stringify(e))}function ie(e){localStorage.setItem("sofarr-request-statuses",JSON.stringify(e))}function Je(e){localStorage.setItem("sofarr-request-sort",e)}function Qe(e){localStorage.setItem("sofarr-request-search",e)}function Q(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 Ke(){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,Oe(o),x(!0))}),t&&t.addEventListener("click",()=>x(!0)),s&&(s.checked=n.ignoreAvailable,s.addEventListener("change",()=>{n.ignoreAvailable=s.checked,je(n.ignoreAvailable),ce(n.lastHistoryItems)})),document.addEventListener("historyReload",()=>{x(!0)})}function Xe(){le(),n.historyRefreshHandle=setInterval(()=>x(),Ee)}function le(){n.historyRefreshHandle&&(clearInterval(n.historyRefreshHandle),n.historyRefreshHandle=null)}function Ve(){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 Ie(e);t.classList.add("hidden"),a.success?(n.lastHistoryItems=a.history,ce(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 ce(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(Ye(a)))}function Ye(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=M(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=Q(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=se(e.episodes);u&&s.appendChild(u)}if(e.movieName){const l=document.createElement("p");l.className="history-media-name";const f=Q(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(K("Completed",$e(e.completedAt))),e.quality&&d.appendChild(K("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 K(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 $(){de();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||"-",oe(),yt(),s&&(s=!1,vt())}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 de(){n.sseSource&&(n.sseSource.close(),n.sseSource=null,console.log("[SSE] Stream closed"))}function Ze(e){n.showAll=e,$();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}function et(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",tt),document.getElementById("enable-sonarr-webhook").addEventListener("click",nt),document.getElementById("enable-radarr-webhook").addEventListener("click",ot),document.getElementById("enable-ombi-webhook").addEventListener("click",it),document.getElementById("test-sonarr-webhook").addEventListener("click",at),document.getElementById("test-radarr-webhook").addEventListener("click",rt),document.getElementById("test-ombi-webhook").addEventListener("click",lt))}function tt(){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&&ue()}async function ue(){const e=document.getElementById("webhook-loading");e.classList.remove("hidden");try{(await L()).success&&st()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.classList.add("hidden")}}function st(){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 nt(){v(!0);try{const e=await Te();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 ot(){v(!0);try{const e=await xe();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 at(){v(!0);try{const e=await Ne();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 rt(){v(!0);try{const e=await Re();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 it(){v(!0);try{const e=await De();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 lt(){v(!0);try{const e=await Ae();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 ct(){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 ue()):t&&t.classList.add("hidden"),X(),n.statusRefreshHandle&&clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=setInterval(X,ke)}function dt(){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 X(){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 Fe();o.success?(console.log("[Status] Got status data, rendering..."),ut(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 ut(e,t){var E,T,k,U,P,O,j,_,G;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 w=Math.round((Date.now()-g.connectedAt)/1e3);d+=`
${N(g.user)}connected ${w}s ago
`}if(d+="
",n.isAdmin&&e.webhooks){const g=e.webhooks,w=(E=g.sonarr)!=null&&E.enabled?"●":"○",S=(T=g.radarr)!=null&&T.enabled?"●":"○",C=((k=g.sonarr)==null?void 0:k.eventsReceived)||0,ge=((U=g.radarr)==null?void 0:U.eventsReceived)||0,be=((P=g.sonarr)==null?void 0:P.pollsSkipped)||0,ye=((O=g.radarr)==null?void 0:O.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${w} ${(j=g.sonarr)!=null&&j.enabled?"Enabled":"Disabled"}
+
Radarr${S} ${(_=g.radarr)!=null&&_.enabled?"Enabled":"Disabled"}
+
EventsS:${C} R:${ge}
+
Polls skippedS:${be} R:${ye}
+ `}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 w=p.tasks.reduce((S,C)=>Math.max(S,C.ms),1);for(const S of p.tasks){const C=Math.max(2,S.ms/w*100);d+=`
- ${D(E.label)} -
- ${E.ms}ms -
`}u+="
"}u+=` + ${N(S.label)} +
+ ${S.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 w=g.sizeBytes>1024?(g.sizeBytes/1024).toFixed(1)+" KB":g.sizeBytes+" B",S=g.expired?'expired':(g.ttlRemainingMs/1e3).toFixed(0)+"s",C=g.itemCount!==null?g.itemCount:"—";d+=`${N(g.key)}${C}${w}${S}`}d+="";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(G=y==null?void 0:y.children)==null?void 0:G.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",dt),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 mt(){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 ht(){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,ve-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 pt(){const e=Date.now();try{(await Se()).authenticated?(me(),he(),$(),await R(e)):(await R(e),F())}catch(t){console.error("Authentication check failed:",t),await R(e),F()}}async function ft(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,o=document.getElementById("remember-me").checked;try{const a=await we(t,s,o);if(a.success){await mt(),ht(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),me(),he();const r=Date.now();$(),await R(r)}else V(a.error||"Login failed")}catch(a){V("Login failed. Please try again."),console.error(a)}}async function gt(){try{de(),le(),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null),await Ce(),n.currentUser=null,Ve(),F()}catch(e){console.error("Logout failed:",e)}}function F(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),bt()}function me(){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),Xe()}function V(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function bt(){document.getElementById("login-error").classList.add("hidden")}function yt(){document.getElementById("error-message").classList.add("hidden")}function he(){document.getElementById("loading").classList.remove("hidden")}function vt(){document.getElementById("loading").classList.add("hidden")}function Et(){const e=document.getElementById("download-client-filter-btn"),t=document.getElementById("download-client-filter-dropdown"),s=document.getElementById("download-client-filter-close");!e||!t||(e.addEventListener("click",o=>{o.stopPropagation(),t.classList.toggle("open")}),s.addEventListener("click",()=>{t.classList.remove("open")}),document.addEventListener("click",o=>{!t.contains(o.target)&&o.target!==e&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",Y),Y())}function Y(){const e=document.getElementById("download-client-filter-list");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",()=>kt(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)}),pe())}function kt(e){const t=n.selectedDownloadClients.indexOf(e);t>-1?n.selectedDownloadClients.splice(t,1):n.selectedDownloadClients.push(e),_e(n.selectedDownloadClients),pe(),oe()}function pe(){const e=document.getElementById("download-client-filter-count");e&&(n.selectedDownloadClients.length===0?e.textContent="All":e.textContent=n.selectedDownloadClients.length)}function fe(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 wt(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return e.filter(o=>s.includes(fe(o)))}function Ct(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(o=>(o.title||"").toLowerCase().includes(s))}function It(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 Bt(e,{types:t,statuses:s,sort:o,search:a}={}){let r=[...e];return r=St(r,t),r=wt(r,s),r=Ct(r,a),r=It(r,o),r}function Lt(e){return e?e.requestedUser&&typeof e.requestedUser=="object"?e.requestedUser.alias||e.requestedUser.userAlias||e.requestedUser.userName||e.requestedUser.normalizedUserName||"":e.requestedUser||e.requestedByAlias||"":""}function q(){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=Bt(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=qt(r);e.appendChild(c)})}function qt(e){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=Tt(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=Lt(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}/${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 Tt(e){const t=document.createElement("span");t.className="request-status-badge";const s=fe(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 xt(){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",()=>Z(!0)),o==null||o.addEventListener("click",()=>Z(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Nt(c,r.checked)})}),W()}function Z(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:[],re(n.selectedRequestTypes),W(),q()}function Nt(e,t){const s=n.selectedRequestTypes.indexOf(e);t&&s===-1?n.selectedRequestTypes.push(e):!t&&s>-1&&n.selectedRequestTypes.splice(s,1),re(n.selectedRequestTypes),W(),q()}function W(){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?e.textContent="None":n.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=n.selectedRequestTypes.length}function Rt(){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",()=>ee(!0)),o==null||o.addEventListener("click",()=>ee(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Dt(c,r.checked)})}),H()}function ee(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:[],ie(n.selectedRequestStatuses),H(),q()}function Dt(e,t){const s=n.selectedRequestStatuses.indexOf(e);t&&s===-1?n.selectedRequestStatuses.push(e):!t&&s>-1&&n.selectedRequestStatuses.splice(s,1),ie(n.selectedRequestStatuses),H(),q()}function H(){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 At(){const e=document.getElementById("request-sort-select");e&&(e.value=n.requestSortMode,e.addEventListener("change",t=>{n.requestSortMode=t.target.value,Je(n.requestSortMode),q()}))}function Ft(){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,Qe(n.requestSearchQuery),q()},200)})}function Mt(){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 $t(){xt(),Rt(),At(),Ft(),Mt(),document.addEventListener("ombiRequestsUpdated",()=>{q()})}(function(){const t=ae();t&&document.documentElement.setAttribute("data-theme",t)})();function Wt(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const s=ae()==="dark"?"light":"dark";Ht(s)})}function Ht(e){document.documentElement.setAttribute("data-theme",e),Ge(e)}function Ut(){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=ze();B(o==="requests"?"requests":o==="history"?"history":"downloads"),e.addEventListener("click",()=>B("downloads")),t&&t.addEventListener("click",()=>B("requests")),s.addEventListener("click",()=>B("history"))}function B(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"),setupRequestsTab(),o&&o.classList.add("active"),c&&c.classList.remove("hidden"),A("history"),x())}function Pt(){B("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",ft);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",gt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>Ze(r.target.checked));const o=document.getElementById("status-btn");o&&o.addEventListener("click",ct);const a=document.getElementById("home-btn");a&&a.addEventListener("click",Pt),Wt(),Ut(),Et(),$t(),Ke(),et(),Le().then(r=>{const c=document.getElementById("app-version");c&&r&&(c.textContent="v"+r)}),pt()}); diff --git a/public/index.html b/public/index.html index 4939009..8908e42 100644 --- a/public/index.html +++ b/public/index.html @@ -201,7 +201,81 @@