diff --git a/.env.sample b/.env.sample index 578cab5..d3901d6 100644 --- a/.env.sample +++ b/.env.sample @@ -169,6 +169,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo # ============================================================================= OMBI_URL=https://ombi.example.com OMBI_API_KEY=your-ombi-api-key-here +# Optional: Delay in milliseconds to wait before refreshing the cache after receiving an Ombi webhook +# to resolve the race condition where Ombi fires the webhook before committing to its database. +# OMBI_WEBHOOK_REFRESH_DELAY_MS=2000 # ============================================================================= # NOTES diff --git a/CHANGELOG.md b/CHANGELOG.md index df24161..1aa49b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.22] - 2026-05-27 + +### Fixed + +- **Theme Switcher Multi-Button Support (Issue #54)** — Resolved a frontend bug where themes other than Light failed to apply because the Javascript code queried a non-existent `#theme-toggle` element. Re-engineered the switcher to query all `.theme-btn` selectors inside `.theme-switcher`, managing and toggling the active class styling across `light`, `dark`, and `mono` themes, and cleanly persisting choices to local storage. +- **Ombi TV Requester User Extraction Fallback (Issue #53)** — Resolved a bug where TV show requests from Ombi displayed as "unknown" user because requester attributes were nested under non-standard properties (`user`, `requestedBy`, `ombiUser`, `requestedByUser`, and nested seasons/child requests arrays). Added robust multi-layer extraction logic on both backend and frontend layers to resolve requester usernames under all TV request structures. +- **Configurable Webhook Commit Delay & Retry Loop (Issue #53)** — Mitigated race conditions between Ombi webhook events and database commits by introducing a configurable `OMBI_WEBHOOK_REFRESH_DELAY_MS` delay (defaulting to `2000`) and a smart 3-attempt retry polling loop to verify updated requester data before cache updates. + +### Added + +- **Admin Request Card *arr Library Lookup (Issue #53)** — Added library deep-link lookup for admin views. Request cards now query Sonarr and Radarr library caches and dynamically render deep-link navigation icons in the card actions container if the item exists. +- **Request Date/Time Presentation** — Added request date and time display inside request cards formatted as `YYYY-MM-DD HH:MM`. +- **Unknown (Ombi) Dotted Underline Tooltip** — Added user-friendly placeholder "Unknown (Ombi)" with dotted underline and explanatory hover tooltip when user details are unavailable from the Ombi database. +- **Expanded Test Coverage** — Introduced two new frontend DOM test suites (`tests/frontend/ui/theme.test.js` and `tests/frontend/ui/requests.test.js`) and robust backend unit test assertions in `tests/unit/ombiHelpers.test.js`. + +--- + ## [1.7.21] - 2026-05-26 ### Fixed diff --git a/client/src/ui/requests.js b/client/src/ui/requests.js index c3a0e1d..1c2f12d 100644 --- a/client/src/ui/requests.js +++ b/client/src/ui/requests.js @@ -17,17 +17,52 @@ import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js'; function extractRequestedUser(request) { if (!request) return ''; - // Handle object format: OmbiStore.Entities.OmbiUser - if (request.requestedUser && typeof request.requestedUser === 'object') { - // Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias - return request.requestedUser.alias || - request.requestedUser.userAlias || - request.requestedUser.userName || - request.requestedUser.normalizedUserName || - request.requestedByAlias || ''; + // Try to locate a user object or string from various fields common to Ombi Movies and TV shows + const userSource = request.requestedUser || request.RequestedUser || + request.user || request.User || + request.requestedBy || request.RequestedBy || + request.ombiUser || request.OmbiUser || + request.requestedByUser || request.RequestedByUser; + + // If userSource is an object, extract key fields + if (userSource && typeof userSource === 'object') { + const username = userSource.alias || userSource.Alias || + userSource.userAlias || userSource.UserAlias || + userSource.userName || userSource.UserName || + userSource.normalizedUserName || userSource.NormalizedUserName || + userSource.displayName || userSource.DisplayName || + userSource.email || userSource.Email; + if (username) return username; } - // Handle string format (fallback for compatibility) - return request.requestedUser || request.requestedByAlias || ''; + + // If userSource is a string + if (userSource && typeof userSource === 'string') { + return userSource; + } + + // Fallbacks on the request root level + const rootFallback = request.requestedByAlias || request.RequestedByAlias || + request.requestedByUsername || request.RequestedByUsername || + request.requester || request.Requester || + request.requestedByEmail || request.RequestedByEmail; + if (rootFallback) return rootFallback; + + // Check seasons / childRequests nested arrays (common for Ombi TV show requests) + if (Array.isArray(request.seasons)) { + for (const season of request.seasons) { + const seasonUser = extractRequestedUser(season); + if (seasonUser) return seasonUser; + } + } + + if (Array.isArray(request.childRequests)) { + for (const child of request.childRequests) { + const childUser = extractRequestedUser(child); + if (childUser) return childUser; + } + } + + return ''; } export function renderRequests() { @@ -111,11 +146,38 @@ function createRequestCard(request) { } const username = extractRequestedUser(request); + const user = document.createElement('span'); + user.className = 'request-user'; if (username) { - const user = document.createElement('span'); - user.className = 'request-user'; user.textContent = `Requested by: ${username}`; - meta.appendChild(user); + } else { + user.textContent = 'Requested by: Unknown (Ombi)'; + user.title = 'No user information received from Ombi'; + user.style.cursor = 'help'; + user.style.textDecoration = 'underline dotted'; + } + meta.appendChild(user); + + const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date; + if (dateStr) { + const requestDate = document.createElement('span'); + requestDate.className = 'request-date'; + try { + const dateObj = new Date(dateStr); + if (!isNaN(dateObj.getTime())) { + const year = dateObj.getFullYear(); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getDate()).padStart(2, '0'); + const hours = String(dateObj.getHours()).padStart(2, '0'); + const minutes = String(dateObj.getMinutes()).padStart(2, '0'); + requestDate.textContent = `Date: ${year}-${month}-${day} ${hours}:${minutes}`; + } else { + requestDate.textContent = `Date: ${dateStr}`; + } + } catch (e) { + requestDate.textContent = `Date: ${dateStr}`; + } + meta.appendChild(requestDate); } if (request.quality) { @@ -147,6 +209,22 @@ function createRequestCard(request) { actions.appendChild(ombiLink); } + if (state.isAdmin && request.arrLink) { + const arrLink = document.createElement('a'); + arrLink.className = `request-link ${request.arrType}-link`; + arrLink.href = request.arrLink; + arrLink.target = '_blank'; + arrLink.title = `View in ${request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'}`; + + const arrIcon = document.createElement('img'); + arrIcon.src = request.arrType === 'sonarr' ? '/images/sonarr.svg' : '/images/radarr.svg'; + arrIcon.alt = request.arrType === 'sonarr' ? 'Sonarr' : 'Radarr'; + arrIcon.className = 'request-icon'; + + arrLink.appendChild(arrIcon); + actions.appendChild(arrLink); + } + card.appendChild(typeIcon); card.appendChild(content); card.appendChild(actions); diff --git a/client/src/ui/theme.js b/client/src/ui/theme.js index c880a77..38789ba 100644 --- a/client/src/ui/theme.js +++ b/client/src/ui/theme.js @@ -4,24 +4,43 @@ import { getTheme, saveTheme } from '../utils/storage.js'; // Apply saved theme immediately on load (function applyTheme() { - const theme = getTheme(); - if (theme) { - document.documentElement.setAttribute('data-theme', theme); - } + const theme = getTheme() || 'light'; + document.documentElement.setAttribute('data-theme', theme); })(); export function initThemeSwitcher() { - const themeToggle = document.getElementById('theme-toggle'); - if (!themeToggle) return; + const themeButtons = document.querySelectorAll('.theme-btn'); + const currentTheme = getTheme() || 'light'; - themeToggle.addEventListener('click', () => { - const currentTheme = getTheme(); - const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; - setTheme(newTheme); + // Set initial active state on buttons + themeButtons.forEach(btn => { + if (btn.getAttribute('data-theme') === currentTheme) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + + btn.addEventListener('click', () => { + const theme = btn.getAttribute('data-theme'); + if (theme) { + setTheme(theme); + } + }); }); } export function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); saveTheme(theme); + + // Sync button active classes if elements are present on the page + const themeButtons = document.querySelectorAll('.theme-btn'); + themeButtons.forEach(btn => { + if (btn.getAttribute('data-theme') === theme) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); } + diff --git a/package-lock.json b/package-lock.json index efac3eb..22dc4a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.21", + "version": "1.7.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.21", + "version": "1.7.22", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 93340f6..dd6d588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.21", + "version": "1.7.22", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/public/app.js b/public/app.js index 94a18df..9412ff3 100644 --- a/public/app.js +++ b/public/app.js @@ -1,11 +1,11 @@ -const n={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,ombiBaseUrl:null,ombiRequests:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},ombiWebhook:{enabled:!1,triggers:{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},stats:null},webhookMetrics:null,selectedRequestTypes:["movie","tv"],selectedRequestStatuses:[],requestSortMode:"requestedDate_desc",requestSearchQuery:""},ke=1200,we=5*60*1e3,Se=5e3;async function Ce(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),s=await e.json(),o=await t.json();return o.csrfToken&&(n.csrfToken=o.csrfToken),s.authenticated?(n.currentUser=s.user,n.isAdmin=!!s.user.isAdmin,{authenticated:!0,user:s.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function Ie(e,t,s){try{const a=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:s})})).json();return a.success?(n.currentUser=a.user,n.isAdmin=!!a.user.isAdmin,a.csrfToken&&(n.csrfToken=a.csrfToken),{success:!0,user:a.user}):{success:!1,error:a.error||"Login failed"}}catch(o){return console.error(o),{success:!1,error:"Login failed. Please try again."}}}async function Be(){try{return await fetch("/api/auth/logout",{method:"POST",headers:n.csrfToken?{"X-CSRF-Token":n.csrfToken}:{}}),n.currentUser=null,n.csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function Le(e=!1){try{const t=new URLSearchParams({days:n.historyDays});n.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const s=await fetch(`/api/history/recent?${t}`);if(!s.ok)throw new Error(`HTTP ${s.status}`);return{success:!0,history:(await s.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function qe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentType:e.arrContentType})});if(!t.ok){const s=await t.json().catch(()=>({}));throw new Error(s.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function Te(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function xe(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function q(){var e,t;try{const s=xe();let o=!1;try{const h=await fetch("/api/webhook/config");h.ok&&(o=(await h.json()).valid||!1)}catch{}let a=!1,r={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/sonarr/notifications");if(h.ok){const b=(await h.json()).find(E=>E.name==="Sofarr");a=o&&!!b,b&&(r={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let c=!1,m={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const h=await fetch("/api/radarr/notifications");if(h.ok){const b=(await h.json()).find(E=>E.name==="Sofarr");c=o&&!!b,b&&(m={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let d=!1,l={requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},f=null;try{const h=await fetch("/api/ombi/webhook/status");if(h.ok){const y=await h.json();d=y.enabled||!1,l=y.triggers||{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},f=y.stats||null}}catch{}n.webhookMetrics=await s;const i=n.webhookMetrics?Object.entries(n.webhookMetrics.instances||{}):[],u=((e=i.find(([h])=>h.includes("sonarr")))==null?void 0:e[1])||null,p=((t=i.find(([h])=>h.includes("radarr")))==null?void 0:t[1])||null;return n.sonarrWebhook={enabled:a,triggers:r,stats:u},n.radarrWebhook={enabled:c,triggers:m,stats:p},n.ombiWebhook={enabled:d,triggers:l,stats:f},{success:!0}}catch(s){return console.error("Failed to fetch webhook status:",s),{success:!1}}}async function Ne(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function Re(){try{if(!(await fetch("/api/radarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function De(){try{const e=await fetch("/api/sonarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const s=(await e.json()).find(a=>a.name==="Sofarr");if(!s)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/sonarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function Ae(){try{const e=await fetch("/api/radarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const s=(await e.json()).find(a=>a.name==="Sofarr");if(!s)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/radarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":n.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function Fe(){try{if(!(await fetch("/api/ombi/webhook/enable",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Failed to enable");return await q(),{success:!0}}catch(e){return console.error("Failed to enable Ombi webhook:",e),{success:!1,error:e.message}}}async function Me(){try{if(!(await fetch("/api/ombi/webhook/test",{method:"POST",headers:{"X-CSRF-Token":n.csrfToken||""}})).ok)throw new Error("Test failed");return await q(),{success:!0}}catch(e){return console.error("Failed to test Ombi webhook:",e),{success:!1,error:e.message}}}async function $e(){try{const e=await fetch("/api/status");if(!e.ok)throw new Error("Failed to fetch status: "+e.status);return{success:!0,data:await e.json()}}catch(e){return console.error("[Status] Error fetching status:",e),{success:!1,error:e.message}}}function We(e){if(!e)return"N/A";if(typeof e=="string")return e;const t=["B","KB","MB","GB","TB"],s=Math.floor(Math.log(e)/Math.log(1024));return Math.round(e/Math.pow(1024,s)*100)/100+" "+t[s]}function ne(e){if(!e||e===0)return"0 B/s";const t=["B/s","KB/s","MB/s","GB/s"];let s=e,o=0;for(;s>=1024&&o{const a="S"+String(o.season).padStart(2,"0")+"E"+String(o.episode).padStart(2,"0");return o.title?a+" — "+o.title:a});t.setAttribute("data-tooltip",s.join(` -`))}return t}function $(e,t,s){const o=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(c=>!c.matchedUser),r=e.filter(c=>c.matchedUser);for(const c of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=c.label,o.appendChild(m)}for(const c of r){const m=document.createElement("span");m.className="download-user-badge",m.textContent=c.matchedUser,o.appendChild(m)}}else if(s){const a=document.createElement("span");a.className="download-user-badge",a.textContent=s,o.appendChild(a)}return o}function ae(e){const t=document.createElement("span");t.className="download-client-logo-wrapper download-card-logo-wrapper";const s=document.createElement("img");return s.className="download-client-logo",s.src=`/images/clients/${e.client}.svg`,s.alt=`${e.instanceName||e.client} icon`,s.title=e.instanceName||e.client,s.onerror=()=>{t.textContent=e.client.charAt(0).toUpperCase(),t.classList.add("fallback")},t.appendChild(s),t}function K(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi",s.href=e.ombiLink;const o=document.createElement("a");o.href=e.ombiLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}if(n.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const o=document.createElement("a");o.href=e.arrLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}return t}function W(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let s=n.downloads;if(n.selectedDownloadClients.length>0){const r=n.selectedDownloadClients.map(c=>n.downloadClients[c]).filter(Boolean);s=n.downloads.filter(c=>r.some(m=>m.type===c.client&&m.id===c.instanceId))}if(n.downloadClients.length>0){const r=new Map(n.downloadClients.map((c,m)=>[c.id,m]));s=[...s].sort((c,m)=>{const d=r.get(c.instanceId)??1/0,l=r.get(m.instanceId)??1/0;return d-l})}if(s.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const o=new Map;e.querySelectorAll(".download-card").forEach(r=>{o.set(r.dataset.id,r)});const a=new Set;s.forEach(r=>{const c=r.title;a.add(c);const m=o.get(c);if(m)Ue(m,r);else{const d=Oe(r);e.appendChild(d)}}),o.forEach((r,c)=>{a.has(c)||r.remove()})}function Ue(e,t){const s=e.querySelector(".download-header-right");s&&s.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(i=>i.remove());const a=e.querySelector(".download-header .download-client-logo-wrapper");a&&a.remove();const r=e.querySelector(".download-card-logo-wrapper");r&&r.remove();const c=e.querySelector(".download-header");if(c&&!c.querySelector(".download-header-right")){const i=document.createElement("div");i.className="download-header-right";const u=$(t.tagBadges,n.showAll,t.matchedUserTag);i.appendChild(u),c.appendChild(i)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(ae(t));const m=e.querySelector(".download-status");m&&m.textContent!==t.status&&(m.textContent=t.status,m.className=`download-status ${t.status}`);const d=e.querySelector(".progress-container");if(d&&t.progress!==void 0){const i=d.querySelector(".progress-bar"),u=d.querySelector(".progress-text"),p=d.querySelector(".missing-text");if(i){const h=i.querySelector(".downloaded");h&&(h.style.width=t.progress+"%")}if(u&&(u.textContent=t.progress+"%"),p){const h=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&h>0?p.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`:p.textContent=""}}const l=e.querySelector('.detail-item[data-label="Speed"] .detail-value');l&&t.speed!==void 0&&(l.textContent=ne(t.speed));const f=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(f&&t.eta!==void 0&&(f.textContent=t.eta),t.qbittorrent){const i=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');i&&t.seeds!==void 0&&(i.textContent=t.seeds);const u=e.querySelector('.detail-item[data-label="Peers"] .detail-value');u&&t.peers!==void 0&&(u.textContent=t.peers);const p=e.querySelector('.detail-item[data-label="Availability"]');p&&t.availability!==void 0&&(p.querySelector(".detail-value").textContent=`${t.availability}%`,p.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function Pe(e,t){if(console.log("[Blocklist] Clicked, download:",t),console.log("[Blocklist] Required fields:",{arrQueueId:t.arrQueueId,arrType:t.arrType,arrInstanceUrl:t.arrInstanceUrl,arrInstanceKey:t.arrInstanceKey,arrContentId:t.arrContentId,arrContentType:t.arrContentType,isAdmin:n.isAdmin,canBlocklist:t.canBlocklist}),!!confirm(`Blocklist "${t.title}" and trigger a new search? +const o={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:""},Fe=1200,$e=5*60*1e3,Me=5e3;async function Ue(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),s=await e.json(),n=await t.json();return n.csrfToken&&(o.csrfToken=n.csrfToken),s.authenticated?(o.currentUser=s.user,o.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?(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(n){return console.error(n),{success:!1,error:"Login failed. Please try again."}}}async function He(){try{return await fetch("/api/auth/logout",{method:"POST",headers:o.csrfToken?{"X-CSRF-Token":o.csrfToken}:{}}),o.currentUser=null,o.csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function Oe(e=!1){try{const t=new URLSearchParams({days:o.historyDays});o.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 Pe(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":o.csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentIds:e.arrContentIds,arrSeriesId:e.arrSeriesId,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 je(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function _e(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function T(){var e,t;try{const s=_e();let n=!1;try{const h=await fetch("/api/webhook/config");h.ok&&(n=(await h.json()).valid||!1)}catch{}let a=!1,i={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(v=>v.name==="Sofarr");a=n&&!!b,b&&(i={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let l=!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(v=>v.name==="Sofarr");l=n&&!!b,b&&(m={onGrab:b.onGrab,onDownload:b.onDownload,onImport:b.onImport,onUpgrade:b.onUpgrade})}}catch{}let u=!1,c={requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=null;try{const h=await fetch("/api/ombi/webhook/status");if(h.ok){const y=await h.json();u=y.enabled||!1,c=y.triggers||{requestAvailable:!1,requestApproved:!1,requestDeclined:!1,requestPending:!1,requestProcessing:!1},g=y.stats||null}}catch{}o.webhookMetrics=await s;const r=o.webhookMetrics?Object.entries(o.webhookMetrics.instances||{}):[],d=((e=r.find(([h])=>h.includes("sonarr")))==null?void 0:e[1])||null,p=((t=r.find(([h])=>h.includes("radarr")))==null?void 0:t[1])||null;return o.sonarrWebhook={enabled:a,triggers:i,stats:d},o.radarrWebhook={enabled:l,triggers:m,stats:p},o.ombiWebhook={enabled:u,triggers:c,stats:g},{success:!0}}catch(s){return console.error("Failed to fetch webhook status:",s),{success:!1}}}async function Ge(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Failed to enable");return await T(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function ze(){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 T(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function Je(){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":o.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await T(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function Qe(){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":o.csrfToken||""},body:JSON.stringify(s)})).ok)throw new Error("Test failed");return await T(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function Ke(){try{if(!(await fetch("/api/ombi/webhook/enable",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Failed to enable");return await T(),{success:!0}}catch(e){return console.error("Failed to enable Ombi webhook:",e),{success:!1,error:e.message}}}async function Xe(){try{if(!(await fetch("/api/ombi/webhook/test",{method:"POST",headers:{"X-CSRF-Token":o.csrfToken||""}})).ok)throw new Error("Test failed");return await T(),{success:!0}}catch(e){return console.error("Failed to test Ombi webhook:",e),{success:!1,error:e.message}}}async function Ve(){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 Ye(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 he(e){if(!e||e===0)return"0 B/s";const t=["B/s","KB/s","MB/s","GB/s"];let s=e,n=0;for(;s>=1024&&n{const a="S"+String(n.season).padStart(2,"0")+"E"+String(n.episode).padStart(2,"0");return n.title?a+" — "+n.title:a});t.setAttribute("data-tooltip",s.join(` +`))}return t}function P(e,t,s){const n=document.createDocumentFragment();if(t&&e&&e.length>0){const a=e.filter(l=>!l.matchedUser),i=e.filter(l=>l.matchedUser);for(const l of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=l.label,n.appendChild(m)}for(const l of i){const m=document.createElement("span");m.className="download-user-badge",m.textContent=l.matchedUser,n.appendChild(m)}}else if(s){const a=document.createElement("span");a.className="download-user-badge",a.textContent=s,n.appendChild(a)}return n}function fe(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 oe(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 n=document.createElement("a");n.href=e.ombiLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}if(o.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 n=document.createElement("a");n.href=e.arrLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}return t}function j(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let s=o.downloads;if(o.selectedDownloadClients.length>0){const i=o.selectedDownloadClients.map(l=>o.downloadClients[l]).filter(Boolean);s=o.downloads.filter(l=>i.some(m=>m.type===l.client&&m.id===l.instanceId))}if(o.downloadClients.length>0){const i=new Map(o.downloadClients.map((l,m)=>[l.id,m]));s=[...s].sort((l,m)=>{const u=i.get(l.instanceId)??1/0,c=i.get(m.instanceId)??1/0;return u-c})}if(s.length===0){t.classList.remove("hidden"),e.innerHTML="";return}t.classList.add("hidden");const n=new Map;e.querySelectorAll(".download-card").forEach(i=>{n.set(i.dataset.id,i)});const a=new Set;s.forEach(i=>{const l=i.title;a.add(l);const m=n.get(l);if(m)et(m,i);else{const u=st(i);e.appendChild(u)}}),n.forEach((i,l)=>{a.has(l)||i.remove()})}function et(e,t){const s=e.querySelector(".download-header-right");s&&s.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 i=e.querySelector(".download-card-logo-wrapper");i&&i.remove();const l=e.querySelector(".download-header");if(l&&!l.querySelector(".download-header-right")){const r=document.createElement("div");r.className="download-header-right";const d=P(t.tagBadges,o.showAll,t.matchedUserTag);r.appendChild(d),l.appendChild(r)}t.client&&!e.querySelector(".download-card-logo-wrapper")&&e.appendChild(fe(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 h=r.querySelector(".downloaded");h&&(h.style.width=t.progress+"%")}if(d&&(d.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 c=e.querySelector('.detail-item[data-label="Speed"] .detail-value');c&&t.speed!==void 0&&(c.textContent=he(t.speed));const g=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(g&&t.eta!==void 0&&(g.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 tt(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,arrContentIds:t.arrContentIds,arrSeriesId:t.arrSeriesId,arrContentType:t.arrContentType,isAdmin:o.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 qe(t),e.textContent="✓ Done — searching…",e.className="blocklist-search-btn success"}catch(s){console.error("[Blocklist] Error:",s),e.disabled=!1,e.textContent="⛔ Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${s.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger an automatic search"},4e3)}}}function Oe(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const i=document.createElement("div");i.className="download-cover";const u=document.createElement("img");u.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",u.alt=e.movieName||e.seriesName||e.title,u.loading="lazy",i.appendChild(u),t.appendChild(i)}const s=document.createElement("div");s.className="download-info";const o=document.createElement("div");o.className="download-header";const a=document.createElement("span");if(a.className=`download-type ${e.type}`,e.type==="series")a.textContent="📺 Series";else if(e.type==="movie")a.textContent="🎬 Movie";else if(e.type==="torrent"){const i=e.instanceName?` (${e.instanceName})`:"";a.textContent=`📥 Torrent${i}`}else a.textContent=e.type;const r=document.createElement("span");if(r.className=`download-status ${e.status}`,r.textContent=e.status,o.appendChild(a),o.appendChild(r),e.importIssues&&e.importIssues.length>0){const i=document.createElement("span");i.className="import-issue-badge",i.textContent="Import Pending",i.setAttribute("data-tooltip",e.importIssues.join(` -`)),o.appendChild(i)}if((n.isAdmin||e.canBlocklist)&&e.arrQueueId){const i=document.createElement("button");i.className="blocklist-search-btn",i.textContent="⛔ Blocklist & Search",i.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",i.addEventListener("click",()=>Pe(i,e)),o.appendChild(i)}const c=document.createElement("div");c.className="download-header-right";const m=$(e.tagBadges,n.showAll,e.matchedUserTag);c.appendChild(m),o.appendChild(c),e.client&&t.appendChild(ae(e));const d=document.createElement("h3");if(d.className="download-title",d.textContent=e.title,s.appendChild(o),s.appendChild(d),e.seriesName){const i=document.createElement("p");i.className="download-series";const u=K(e);u.hasChildNodes()&&(i.appendChild(u),i.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Series: ${e.seriesName}`,i.appendChild(p),s.appendChild(i);const h=oe(e.episodes);h&&s.appendChild(h)}if(e.movieName){const i=document.createElement("p");i.className="download-movie";const u=K(e);u.hasChildNodes()&&(i.appendChild(u),i.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Movie: ${e.movieName}`,i.appendChild(p),s.appendChild(i)}const l=document.createElement("div");l.className="download-details";const f=B("Size",We(e.size));if(l.appendChild(f),e.progress!==void 0){const i=document.createElement("div");i.className="detail-item progress-item",i.dataset.label="Progress";const u=document.createElement("span");u.className="detail-label",u.textContent="Progress";const p=document.createElement("div");p.className="progress-container";const h=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,b=parseFloat(e.progress)||0,E=document.createElement("div");if(E.className="progress-bar",b>0){const k=document.createElement("div");k.className="progress-segment downloaded",k.style.width=b+"%",E.appendChild(k)}p.appendChild(E);const T=document.createElement("span");if(T.className="progress-text",T.textContent=e.progress+"%",p.appendChild(T),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&y>0&&h>0){const k=document.createElement("span");k.className="missing-text",k.textContent=`(missing ${y.toFixed(1)} of ${h.toFixed(1)} MB)`,p.appendChild(k)}i.appendChild(u),i.appendChild(p),l.appendChild(i)}if(e.speed&&e.speed>0){const i=B("Speed",ne(e.speed));l.appendChild(i)}if(e.eta){const i=B("ETA",e.eta);l.appendChild(i)}if(e.qbittorrent){if(e.seeds!==void 0){const i=B("Seeds",e.seeds);l.appendChild(i)}if(e.peers!==void 0){const i=B("Peers",e.peers);l.appendChild(i)}if(e.availability!==void 0){const i=B("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&i.classList.add("availability-warning"),l.appendChild(i)}}if(e.completedAt){const i=B("Completed",je(e.completedAt));l.appendChild(i)}if(n.isAdmin&&(e.downloadPath||e.targetPath)){const i=document.createElement("div");if(i.className="download-paths",e.downloadPath){const u=document.createElement("div");u.className="path-item",u.innerHTML='Download: '+Q(e.downloadPath)+"",i.appendChild(u)}if(e.targetPath){const u=document.createElement("div");u.className="path-item",u.innerHTML='Target: '+Q(e.targetPath)+"",i.appendChild(u)}l.appendChild(i)}return s.appendChild(l),t.appendChild(s),t}function B(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const o=document.createElement("span");o.className="detail-label",o.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(o),s.appendChild(a),s}function je(e){return e?new Date(e).toLocaleString():"N/A"}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{n.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(n.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(s){console.error("[Migration] Failed to migrate download client filter:",s)}else try{const s=localStorage.getItem("sofarr-download-clients");n.selectedDownloadClients=s?JSON.parse(s):[]}catch(s){console.error("[Migration] Failed to load download client filter:",s),n.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(n.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");n.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-request-types");t&&(n.selectedRequestTypes=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request types:",t),n.selectedRequestTypes=["movie","tv"]}try{const t=localStorage.getItem("sofarr-request-statuses");t&&(n.selectedRequestStatuses=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request statuses:",t),n.selectedRequestStatuses=[]}try{const t=localStorage.getItem("sofarr-request-sort");t&&(n.requestSortMode=t)}catch(t){console.error("[Storage] Failed to load request sort:",t),n.requestSortMode="requestedDate_desc"}try{const t=localStorage.getItem("sofarr-request-search");t!==null&&(n.requestSearchQuery=t)}catch(t){console.error("[Storage] Failed to load request search:",t),n.requestSearchQuery=""}})();function _e(e){localStorage.setItem("sofarr-history-days",e)}function Ge(e){localStorage.setItem("sofarr-ignore-available",e)}function re(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function ie(){return localStorage.getItem("sofarr-theme")||"light"}function ze(e){localStorage.setItem("sofarr-theme",e)}function Je(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function A(e){localStorage.setItem("sofarr-active-tab",e)}function le(e){localStorage.setItem("sofarr-request-types",JSON.stringify(e))}function ce(e){localStorage.setItem("sofarr-request-statuses",JSON.stringify(e))}function Qe(e){localStorage.setItem("sofarr-request-sort",e)}function Ke(e){localStorage.setItem("sofarr-request-search",e)}function X(e){const t=document.createElement("span");if(t.className="service-icons-container",e.ombiLink){const s=document.createElement("img");s.className="service-icon ombi",s.src="/images/ombi.svg",s.alt="Ombi",s.title=e.ombiTooltip||"Ombi";const o=document.createElement("a");o.href=e.ombiLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}if(n.isAdmin&&e.arrLink){const s=document.createElement("img");e.arrType==="sonarr"?(s.className="service-icon sonarr",s.src="/images/sonarr.svg",s.alt="Sonarr"):e.arrType==="radarr"&&(s.className="service-icon radarr",s.src="/images/radarr.svg",s.alt="Radarr"),s.title=e.arrType==="sonarr"?"Sonarr":"Radarr";const o=document.createElement("a");o.href=e.arrLink,o.target="_blank",o.appendChild(s),t.appendChild(o)}return t}function Xe(){const e=document.getElementById("history-days"),t=document.getElementById("history-refresh-btn"),s=document.getElementById("ignore-available-toggle");e&&e.addEventListener("change",()=>{const o=parseInt(e.value,10);o>0&&o<=90&&(historyDays=o,_e(o),x(!0))}),t&&t.addEventListener("click",()=>x(!0)),s&&(s.checked=n.ignoreAvailable,s.addEventListener("change",()=>{n.ignoreAvailable=s.checked,Ge(n.ignoreAvailable),ue(n.lastHistoryItems)})),document.addEventListener("historyReload",()=>{x(!0)})}function Ve(){de(),n.historyRefreshHandle=setInterval(()=>x(),we)}function de(){n.historyRefreshHandle&&(clearInterval(n.historyRefreshHandle),n.historyRefreshHandle=null)}function Ye(){n.lastHistoryItems=[],document.getElementById("history-list").innerHTML="",document.getElementById("no-history").classList.add("hidden"),document.getElementById("history-error").classList.add("hidden")}async function x(e=!1){document.getElementById("history-list");const t=document.getElementById("history-loading"),s=document.getElementById("history-error"),o=document.getElementById("no-history");t.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("hidden");try{const a=await Le(e);t.classList.add("hidden"),a.success?(n.lastHistoryItems=a.history,ue(n.lastHistoryItems)):(s.textContent=a.error||"Failed to load history.",s.classList.remove("hidden"))}catch(a){t.classList.add("hidden"),s.textContent="Failed to load history.",s.classList.remove("hidden"),console.error("[History] Load error:",a)}}function ue(e){const t=document.getElementById("history-list"),s=document.getElementById("no-history");t.innerHTML="";const o=n.ignoreAvailable?e.filter(a=>!(a.outcome==="failed"&&a.availableForUpgrade)):e;if(!o.length){s.classList.remove("hidden");return}s.classList.add("hidden"),o.forEach(a=>t.appendChild(Ze(a)))}function Ze(e){const t=document.createElement("div");if(t.className=`history-card ${e.type} ${e.outcome}`,e.coverArt){const l=document.createElement("div");l.className="history-cover";const f=document.createElement("img");f.src="/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt),f.alt=e.movieName||e.seriesName||e.title,f.loading="lazy",l.appendChild(f),t.appendChild(l)}const s=document.createElement("div");s.className="history-info";const o=document.createElement("div");o.className="history-card-header";const a=document.createElement("span");a.className=`history-type-badge ${e.type}`,a.textContent=e.type==="series"?"📺 Series":"🎬 Movie",o.appendChild(a);const r=document.createElement("span");if(r.className=`history-outcome-badge ${e.outcome}`,r.textContent=e.outcome==="imported"?"✓ Imported":"✗ Failed",o.appendChild(r),e.availableForUpgrade){const l=document.createElement("span");l.className="history-upgrade-badge",l.title="A previous version of this item is available. An upgrade download has failed.",l.textContent="⬆ Available",o.appendChild(l)}if(e.instanceName){const l=document.createElement("span");l.className="history-instance-badge",l.textContent=e.instanceName,o.appendChild(l)}const c=$(e.tagBadges,n.showAll,e.matchedUserTag);o.appendChild(c),s.appendChild(o);const m=document.createElement("h3");if(m.className="history-title",m.textContent=e.title,s.appendChild(m),e.seriesName){const l=document.createElement("p");l.className="history-media-name";const f=X(e);f.hasChildNodes()&&(l.appendChild(f),l.appendChild(document.createTextNode(" ")));const i=document.createElement("span");i.textContent="Series: "+e.seriesName,l.appendChild(i),s.appendChild(l);const u=oe(e.episodes);u&&s.appendChild(u)}if(e.movieName){const l=document.createElement("p");l.className="history-media-name";const f=X(e);f.hasChildNodes()&&(l.appendChild(f),l.appendChild(document.createTextNode(" ")));const i=document.createElement("span");i.textContent="Movie: "+e.movieName,l.appendChild(i),s.appendChild(l)}const d=document.createElement("div");if(d.className="history-details",e.completedAt&&d.appendChild(V("Completed",He(e.completedAt))),e.quality&&d.appendChild(V("Quality",e.quality)),e.outcome==="failed"&&e.failureMessage){const l=document.createElement("div");l.className="history-failure-message",l.textContent=e.failureMessage,d.appendChild(l)}return s.appendChild(d),t.appendChild(s),t}function V(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const o=document.createElement("span");o.className="detail-label",o.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(o),s.appendChild(a),s}function H(){me();const e=n.showAll?"?showAll=true":"",t=new EventSource("/api/dashboard/stream"+e);n.sseSource=t;let s=!0;t.onmessage=o=>{try{const a=JSON.parse(o.data);if(n.currentUser=a.user,n.isAdmin=!!a.isAdmin,n.downloads=a.downloads,a.downloadClients){n.downloadClients=a.downloadClients;const r=new CustomEvent("downloadClientsUpdated");document.dispatchEvent(r)}if(a.ombiRequests){n.ombiRequests=a.ombiRequests;const r=new CustomEvent("ombiRequestsUpdated");document.dispatchEvent(r)}a.ombiBaseUrl&&(n.ombiBaseUrl=a.ombiBaseUrl),document.getElementById("currentUser").textContent=n.currentUser||"-",W(),vt(),s&&(s=!1,Et())}catch(a){console.error("[SSE] Failed to parse message:",a)}},t.addEventListener("history-update",o=>{try{const a=JSON.parse(o.data);console.log("[SSE] History update received:",a.type);const r=new CustomEvent("historyReload");document.dispatchEvent(r)}catch(a){console.error("[SSE] Failed to parse history-update message:",a)}}),t.onerror=()=>{console.warn("[SSE] Connection lost, browser will retry...")},console.log("[SSE] Stream connected")}function me(){n.sseSource&&(n.sseSource.close(),n.sseSource=null,console.log("[SSE] Stream closed"))}function et(e){n.showAll=e,H();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}function tt(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",st),document.getElementById("enable-sonarr-webhook").addEventListener("click",ot),document.getElementById("enable-radarr-webhook").addEventListener("click",at),document.getElementById("enable-ombi-webhook").addEventListener("click",lt),document.getElementById("test-sonarr-webhook").addEventListener("click",rt),document.getElementById("test-radarr-webhook").addEventListener("click",it),document.getElementById("test-ombi-webhook").addEventListener("click",ct))}function st(){n.webhookSectionExpanded=!n.webhookSectionExpanded;const e=document.getElementById("webhooks-content"),t=document.getElementById("webhooks-toggle");n.webhookSectionExpanded?e.classList.remove("hidden"):e.classList.add("hidden"),t.classList.toggle("expanded",n.webhookSectionExpanded),n.webhookSectionExpanded&&he()}async function he(){const e=document.getElementById("webhook-loading");e.classList.remove("hidden");try{(await q()).success&&nt()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.classList.add("hidden")}}function nt(){const e=document.getElementById("sonarr-status"),t=document.getElementById("enable-sonarr-webhook"),s=document.getElementById("test-sonarr-webhook"),o=document.getElementById("sonarr-triggers"),a=document.getElementById("sonarr-stats");e.textContent=n.sonarrWebhook.enabled?"● Enabled":"○ Disabled",e.className="status-indicator "+(n.sonarrWebhook.enabled?"enabled":"disabled"),n.sonarrWebhook.enabled?(t.classList.add("hidden"),s.classList.remove("hidden"),o.classList.remove("hidden")):(t.classList.remove("hidden"),s.classList.add("hidden"),o.classList.add("hidden")),n.sonarrWebhook.enabled&&(document.getElementById("sonarr-onGrab").textContent=n.sonarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("sonarr-onGrab").className="trigger-value "+(n.sonarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("sonarr-onDownload").textContent=n.sonarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("sonarr-onDownload").className="trigger-value "+(n.sonarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("sonarr-onImport").textContent=n.sonarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("sonarr-onImport").className="trigger-value "+(n.sonarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("sonarr-onUpgrade").textContent=n.sonarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("sonarr-onUpgrade").className="trigger-value "+(n.sonarrWebhook.triggers.onUpgrade?"active":"inactive")),n.sonarrWebhook.stats?(a.classList.remove("hidden"),document.getElementById("sonarr-events").textContent=n.sonarrWebhook.stats.eventsReceived??0,document.getElementById("sonarr-polls").textContent=n.sonarrWebhook.stats.pollsSkipped??0,document.getElementById("sonarr-last").textContent=D(n.sonarrWebhook.stats.lastWebhookTimestamp)):a.classList.add("hidden");const r=document.getElementById("radarr-status"),c=document.getElementById("enable-radarr-webhook"),m=document.getElementById("test-radarr-webhook"),d=document.getElementById("radarr-triggers"),l=document.getElementById("radarr-stats");r.textContent=n.radarrWebhook.enabled?"● Enabled":"○ Disabled",r.className="status-indicator "+(n.radarrWebhook.enabled?"enabled":"disabled"),n.radarrWebhook.enabled?(c.classList.add("hidden"),m.classList.remove("hidden"),d.classList.remove("hidden")):(c.classList.remove("hidden"),m.classList.add("hidden"),d.classList.add("hidden")),n.radarrWebhook.enabled&&(document.getElementById("radarr-onGrab").textContent=n.radarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("radarr-onGrab").className="trigger-value "+(n.radarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("radarr-onDownload").textContent=n.radarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("radarr-onDownload").className="trigger-value "+(n.radarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("radarr-onImport").textContent=n.radarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("radarr-onImport").className="trigger-value "+(n.radarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("radarr-onUpgrade").textContent=n.radarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("radarr-onUpgrade").className="trigger-value "+(n.radarrWebhook.triggers.onUpgrade?"active":"inactive")),n.radarrWebhook.stats?(l.classList.remove("hidden"),document.getElementById("radarr-events").textContent=n.radarrWebhook.stats.eventsReceived??0,document.getElementById("radarr-polls").textContent=n.radarrWebhook.stats.pollsSkipped??0,document.getElementById("radarr-last").textContent=D(n.radarrWebhook.stats.lastWebhookTimestamp)):l.classList.add("hidden");const f=document.getElementById("ombi-status"),i=document.getElementById("enable-ombi-webhook"),u=document.getElementById("test-ombi-webhook"),p=document.getElementById("ombi-triggers"),h=document.getElementById("ombi-stats");f.textContent=n.ombiWebhook.enabled?"● Enabled":"○ Disabled",f.className="status-indicator "+(n.ombiWebhook.enabled?"enabled":"disabled"),n.ombiWebhook.enabled?(i.classList.add("hidden"),u.classList.remove("hidden"),p.classList.remove("hidden")):(i.classList.remove("hidden"),u.classList.add("hidden"),p.classList.add("hidden")),n.ombiWebhook.enabled&&(document.getElementById("ombi-requestAvailable").textContent=n.ombiWebhook.triggers.requestAvailable?"✓":"✗",document.getElementById("ombi-requestAvailable").className="trigger-value "+(n.ombiWebhook.triggers.requestAvailable?"active":"inactive"),document.getElementById("ombi-requestApproved").textContent=n.ombiWebhook.triggers.requestApproved?"✓":"✗",document.getElementById("ombi-requestApproved").className="trigger-value "+(n.ombiWebhook.triggers.requestApproved?"active":"inactive"),document.getElementById("ombi-requestDeclined").textContent=n.ombiWebhook.triggers.requestDeclined?"✓":"✗",document.getElementById("ombi-requestDeclined").className="trigger-value "+(n.ombiWebhook.triggers.requestDeclined?"active":"inactive"),document.getElementById("ombi-requestPending").textContent=n.ombiWebhook.triggers.requestPending?"✓":"✗",document.getElementById("ombi-requestPending").className="trigger-value "+(n.ombiWebhook.triggers.requestPending?"active":"inactive"),document.getElementById("ombi-requestProcessing").textContent=n.ombiWebhook.triggers.requestProcessing?"✓":"✗",document.getElementById("ombi-requestProcessing").className="trigger-value "+(n.ombiWebhook.triggers.requestProcessing?"active":"inactive")),n.ombiWebhook.stats?(h.classList.remove("hidden"),document.getElementById("ombi-events").textContent=n.ombiWebhook.stats.eventsReceived??0,document.getElementById("ombi-polls").textContent=n.ombiWebhook.stats.pollsSkipped??0,document.getElementById("ombi-last").textContent=D(n.ombiWebhook.stats.lastWebhookTimestamp)):h.classList.add("hidden")}async function ot(){v(!0);try{const e=await Ne();e.success||(console.error("Failed to enable Sonarr webhook:",e.error),alert("Failed to enable Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Sonarr webhook:",e),alert("Failed to enable Sonarr webhook. Check console for details.")}finally{v(!1)}}async function at(){v(!0);try{const e=await Re();e.success||(console.error("Failed to enable Radarr webhook:",e.error),alert("Failed to enable Radarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Radarr webhook:",e),alert("Failed to enable Radarr webhook. Check console for details.")}finally{v(!1)}}async function rt(){v(!0);try{const e=await De();e.success?alert("Sonarr webhook test sent successfully!"):(console.error("Failed to test Sonarr webhook:",e.error),alert("Failed to test Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to test Sonarr webhook:",e),alert("Failed to test Sonarr webhook. Check console for details.")}finally{v(!1)}}async function it(){v(!0);try{const e=await Ae();e.success?alert("Radarr webhook test sent successfully!"):(console.error("Failed to test Radarr webhook:",e.error),alert("Failed to test Radarr webhook. Check console for details."))}catch(e){console.error("Failed to test Radarr webhook:",e),alert("Failed to test Radarr webhook. Check console for details.")}finally{v(!1)}}async function lt(){v(!0);try{const e=await Fe();e.success||(console.error("Failed to enable Ombi webhook:",e.error),alert("Failed to enable Ombi webhook. Check console for details."))}catch(e){console.error("Failed to enable Ombi webhook:",e),alert("Failed to enable Ombi webhook. Check console for details.")}finally{v(!1)}}async function ct(){v(!0);try{const e=await Me();e.success?alert("Ombi webhook test sent successfully!"):(console.error("Failed to test Ombi webhook:",e.error),alert("Failed to test Ombi webhook. Check console for details."))}catch(e){console.error("Failed to test Ombi webhook:",e),alert("Failed to test Ombi webhook. Check console for details.")}finally{v(!1)}}function v(e){n.webhookLoading=e,document.getElementById("enable-sonarr-webhook").disabled=e,document.getElementById("enable-radarr-webhook").disabled=e,document.getElementById("enable-ombi-webhook").disabled=e,document.getElementById("test-sonarr-webhook").disabled=e,document.getElementById("test-radarr-webhook").disabled=e,document.getElementById("test-ombi-webhook").disabled=e;const t=document.getElementById("webhook-loading");e?t.classList.remove("hidden"):t.classList.add("hidden")}async function dt(){const e=document.getElementById("status-panel"),t=document.getElementById("webhooks-section");if(!e.classList.contains("hidden")){e.classList.add("hidden"),t&&t.classList.add("hidden"),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null);return}e.classList.remove("hidden"),t&&n.isAdmin?(t.classList.remove("hidden"),n.webhookSectionExpanded=!1,document.getElementById("webhooks-content").classList.add("hidden"),document.getElementById("webhooks-toggle").classList.remove("expanded"),await he()):t&&t.classList.add("hidden"),Y(),n.statusRefreshHandle&&clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=setInterval(Y,Se)}function ut(){document.getElementById("status-panel").classList.add("hidden");const e=document.getElementById("webhooks-section");e&&e.classList.add("hidden"),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null)}async function Y(){var s;const e=document.getElementById("status-panel"),t=document.getElementById("status-content");if(console.log("[Status] panel found:",!!e,"contentDiv found:",!!t,"panel display:",(s=e==null?void 0:e.style)==null?void 0:s.display),!(!e||e.classList.contains("hidden"))){console.log("[Status] Refreshing status panel...");try{const o=await $e();o.success?(console.log("[Status] Got status data, rendering..."),mt(o.data,e)):(console.error("[Status] API returned error:",o.error),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

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

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

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

")}}}function mt(e,t){var E,T,k,O,j,_,G,z,J;console.log("[Status] renderStatusPanel called with data:",e?"yes":"no","keys:",e?Object.keys(e):"none");const s=e.server,o=Math.floor(s.uptimeSeconds/3600),a=Math.floor(s.uptimeSeconds%3600/60),r=s.uptimeSeconds%60,c=`${o}h ${a}m ${r}s`,m=(e.cache.totalSizeBytes/1024).toFixed(1);let d=` +• 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(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 st(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 s=document.createElement("div");s.className="download-info";const n=document.createElement("div");n.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 i=document.createElement("span");if(i.className=`download-status ${e.status}`,i.textContent=e.status,n.appendChild(a),n.appendChild(i),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(` +`)),n.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",()=>tt(r,e)),n.appendChild(r)}const l=document.createElement("div");l.className="download-header-right";const m=P(e.tagBadges,o.showAll,e.matchedUserTag);l.appendChild(m),n.appendChild(l),e.client&&t.appendChild(fe(e));const u=document.createElement("h3");if(u.className="download-title",u.textContent=e.title,s.appendChild(n),s.appendChild(u),e.seriesName){const r=document.createElement("p");r.className="download-series";const d=oe(e);d.hasChildNodes()&&(r.appendChild(d),r.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Series: ${e.seriesName}`,r.appendChild(p),s.appendChild(r);const h=pe(e.episodes);h&&s.appendChild(h)}if(e.movieName){const r=document.createElement("p");r.className="download-movie";const d=oe(e);d.hasChildNodes()&&(r.appendChild(d),r.appendChild(document.createTextNode(" ")));const p=document.createElement("span");p.textContent=`Movie: ${e.movieName}`,r.appendChild(p),s.appendChild(r)}const c=document.createElement("div");c.className="download-details";const g=L("Size",Ye(e.size));if(c.appendChild(g),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 h=parseFloat(e.mb)||parseFloat(e.size),y=parseFloat(e.mbmissing)||0,b=parseFloat(e.progress)||0,v=document.createElement("div");if(v.className="progress-bar",b>0){const k=document.createElement("div");k.className="progress-segment downloaded",k.style.width=b+"%",v.appendChild(k)}p.appendChild(v);const x=document.createElement("span");if(x.className="progress-text",x.textContent=e.progress+"%",p.appendChild(x),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)}r.appendChild(d),r.appendChild(p),c.appendChild(r)}if(e.speed&&e.speed>0){const r=L("Speed",he(e.speed));c.appendChild(r)}if(e.eta){const r=L("ETA",e.eta);c.appendChild(r)}if(e.qbittorrent){if(e.seeds!==void 0){const r=L("Seeds",e.seeds);c.appendChild(r)}if(e.peers!==void 0){const r=L("Peers",e.peers);c.appendChild(r)}if(e.availability!==void 0){const r=L("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&r.classList.add("availability-warning"),c.appendChild(r)}}if(e.completedAt){const r=L("Completed",nt(e.completedAt));c.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: '+ne(e.downloadPath)+"",r.appendChild(d)}if(e.targetPath){const d=document.createElement("div");d.className="path-item",d.innerHTML='Target: '+ne(e.targetPath)+"",r.appendChild(d)}c.appendChild(r)}return s.appendChild(c),t.appendChild(s),t}function L(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const n=document.createElement("span");n.className="detail-label",n.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(n),s.appendChild(a),s}function nt(e){return e?new Date(e).toLocaleString():"N/A"}(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(s){console.error("[Migration] Failed to migrate download client filter:",s)}else try{const s=localStorage.getItem("sofarr-download-clients");o.selectedDownloadClients=s?JSON.parse(s):[]}catch(s){console.error("[Migration] Failed to load download client filter:",s),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(){try{const t=localStorage.getItem("sofarr-request-types");t&&(o.selectedRequestTypes=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request types:",t),o.selectedRequestTypes=["movie","tv"]}try{const t=localStorage.getItem("sofarr-request-statuses");t&&(o.selectedRequestStatuses=JSON.parse(t))}catch(t){console.error("[Storage] Failed to load request statuses:",t),o.selectedRequestStatuses=[]}try{const t=localStorage.getItem("sofarr-request-sort");t&&(o.requestSortMode=t)}catch(t){console.error("[Storage] Failed to load request sort:",t),o.requestSortMode="requestedDate_desc"}try{const t=localStorage.getItem("sofarr-request-search");t!==null&&(o.requestSearchQuery=t)}catch(t){console.error("[Storage] Failed to load request search:",t),o.requestSearchQuery=""}})();function ot(e){localStorage.setItem("sofarr-history-days",e)}function at(e){localStorage.setItem("sofarr-ignore-available",e)}function ge(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function be(){return localStorage.getItem("sofarr-theme")||"light"}function rt(e){localStorage.setItem("sofarr-theme",e)}function it(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function M(e){localStorage.setItem("sofarr-active-tab",e)}function ye(e){localStorage.setItem("sofarr-request-types",JSON.stringify(e))}function ve(e){localStorage.setItem("sofarr-request-statuses",JSON.stringify(e))}function lt(e){localStorage.setItem("sofarr-request-sort",e)}function ct(e){localStorage.setItem("sofarr-request-search",e)}function ae(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 n=document.createElement("a");n.href=e.ombiLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}if(o.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 n=document.createElement("a");n.href=e.arrLink,n.target="_blank",n.appendChild(s),t.appendChild(n)}return t}function dt(){const e=document.getElementById("history-days"),t=document.getElementById("history-refresh-btn"),s=document.getElementById("ignore-available-toggle");e&&e.addEventListener("change",()=>{const n=parseInt(e.value,10);n>0&&n<=90&&(historyDays=n,ot(n),N(!0))}),t&&t.addEventListener("click",()=>N(!0)),s&&(s.checked=o.ignoreAvailable,s.addEventListener("change",()=>{o.ignoreAvailable=s.checked,at(o.ignoreAvailable),ke(o.lastHistoryItems)})),document.addEventListener("historyReload",()=>{N(!0)})}function ut(){Ee(),o.historyRefreshHandle=setInterval(()=>N(),$e)}function Ee(){o.historyRefreshHandle&&(clearInterval(o.historyRefreshHandle),o.historyRefreshHandle=null)}function mt(){o.lastHistoryItems=[],document.getElementById("history-list").innerHTML="",document.getElementById("no-history").classList.add("hidden"),document.getElementById("history-error").classList.add("hidden")}async function N(e=!1){document.getElementById("history-list");const t=document.getElementById("history-loading"),s=document.getElementById("history-error"),n=document.getElementById("no-history");t.classList.remove("hidden"),s.classList.add("hidden"),n.classList.add("hidden");try{const a=await Oe(e);t.classList.add("hidden"),a.success?(o.lastHistoryItems=a.history,ke(o.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 ke(e){const t=document.getElementById("history-list"),s=document.getElementById("no-history");t.innerHTML="";const n=o.ignoreAvailable?e.filter(a=>!(a.outcome==="failed"&&a.availableForUpgrade)):e;if(!n.length){s.classList.remove("hidden");return}s.classList.add("hidden"),n.forEach(a=>t.appendChild(ht(a)))}function ht(e){const t=document.createElement("div");if(t.className=`history-card ${e.type} ${e.outcome}`,e.coverArt){const c=document.createElement("div");c.className="history-cover";const g=document.createElement("img");g.src="/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt),g.alt=e.movieName||e.seriesName||e.title,g.loading="lazy",c.appendChild(g),t.appendChild(c)}const s=document.createElement("div");s.className="history-info";const n=document.createElement("div");n.className="history-card-header";const a=document.createElement("span");a.className=`history-type-badge ${e.type}`,a.textContent=e.type==="series"?"📺 Series":"🎬 Movie",n.appendChild(a);const i=document.createElement("span");if(i.className=`history-outcome-badge ${e.outcome}`,i.textContent=e.outcome==="imported"?"✓ Imported":"✗ Failed",n.appendChild(i),e.availableForUpgrade){const c=document.createElement("span");c.className="history-upgrade-badge",c.title="A previous version of this item is available. An upgrade download has failed.",c.textContent="⬆ Available",n.appendChild(c)}if(e.instanceName){const c=document.createElement("span");c.className="history-instance-badge",c.textContent=e.instanceName,n.appendChild(c)}const l=P(e.tagBadges,o.showAll,e.matchedUserTag);n.appendChild(l),s.appendChild(n);const m=document.createElement("h3");if(m.className="history-title",m.textContent=e.title,s.appendChild(m),e.seriesName){const c=document.createElement("p");c.className="history-media-name";const g=ae(e);g.hasChildNodes()&&(c.appendChild(g),c.appendChild(document.createTextNode(" ")));const r=document.createElement("span");r.textContent="Series: "+e.seriesName,c.appendChild(r),s.appendChild(c);const d=pe(e.episodes);d&&s.appendChild(d)}if(e.movieName){const c=document.createElement("p");c.className="history-media-name";const g=ae(e);g.hasChildNodes()&&(c.appendChild(g),c.appendChild(document.createTextNode(" ")));const r=document.createElement("span");r.textContent="Movie: "+e.movieName,c.appendChild(r),s.appendChild(c)}const u=document.createElement("div");if(u.className="history-details",e.completedAt&&u.appendChild(re("Completed",Ze(e.completedAt))),e.quality&&u.appendChild(re("Quality",e.quality)),e.outcome==="failed"&&e.failureMessage){const c=document.createElement("div");c.className="history-failure-message",c.textContent=e.failureMessage,u.appendChild(c)}return s.appendChild(u),t.appendChild(s),t}function re(e,t){const s=document.createElement("div");s.className="detail-item",s.dataset.label=e;const n=document.createElement("span");n.className="detail-label",n.textContent=e;const a=document.createElement("span");return a.className="detail-value",a.textContent=t,s.appendChild(n),s.appendChild(a),s}function _(){Se();const e=o.showAll?"?showAll=true":"",t=new EventSource("/api/dashboard/stream"+e);o.sseSource=t;let s=!0;t.onmessage=n=>{try{const a=JSON.parse(n.data);if(o.currentUser=a.user,o.isAdmin=!!a.isAdmin,o.downloads=a.downloads,a.downloadClients){o.downloadClients=a.downloadClients;const i=new CustomEvent("downloadClientsUpdated");document.dispatchEvent(i)}if(a.ombiRequests){o.ombiRequests=a.ombiRequests;const i=new CustomEvent("ombiRequestsUpdated");document.dispatchEvent(i)}a.ombiBaseUrl&&(o.ombiBaseUrl=a.ombiBaseUrl),document.getElementById("currentUser").textContent=o.currentUser||"-",j(),Dt(),s&&(s=!1,At())}catch(a){console.error("[SSE] Failed to parse message:",a)}},t.addEventListener("history-update",n=>{try{const a=JSON.parse(n.data);console.log("[SSE] History update received:",a.type);const i=new CustomEvent("historyReload");document.dispatchEvent(i)}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 Se(){o.sseSource&&(o.sseSource.close(),o.sseSource=null,console.log("[SSE] Stream closed"))}function pt(e){o.showAll=e,_();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}function ft(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",gt),document.getElementById("enable-sonarr-webhook").addEventListener("click",yt),document.getElementById("enable-radarr-webhook").addEventListener("click",vt),document.getElementById("enable-ombi-webhook").addEventListener("click",St),document.getElementById("test-sonarr-webhook").addEventListener("click",Et),document.getElementById("test-radarr-webhook").addEventListener("click",kt),document.getElementById("test-ombi-webhook").addEventListener("click",wt))}function gt(){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&&we()}async function we(){const e=document.getElementById("webhook-loading");e.classList.remove("hidden");try{(await T()).success&&bt()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.classList.add("hidden")}}function bt(){const e=document.getElementById("sonarr-status"),t=document.getElementById("enable-sonarr-webhook"),s=document.getElementById("test-sonarr-webhook"),n=document.getElementById("sonarr-triggers"),a=document.getElementById("sonarr-stats");e.textContent=o.sonarrWebhook.enabled?"● Enabled":"○ Disabled",e.className="status-indicator "+(o.sonarrWebhook.enabled?"enabled":"disabled"),o.sonarrWebhook.enabled?(t.classList.add("hidden"),s.classList.remove("hidden"),n.classList.remove("hidden")):(t.classList.remove("hidden"),s.classList.add("hidden"),n.classList.add("hidden")),o.sonarrWebhook.enabled&&(document.getElementById("sonarr-onGrab").textContent=o.sonarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("sonarr-onGrab").className="trigger-value "+(o.sonarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("sonarr-onDownload").textContent=o.sonarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("sonarr-onDownload").className="trigger-value "+(o.sonarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("sonarr-onImport").textContent=o.sonarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("sonarr-onImport").className="trigger-value "+(o.sonarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("sonarr-onUpgrade").textContent=o.sonarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("sonarr-onUpgrade").className="trigger-value "+(o.sonarrWebhook.triggers.onUpgrade?"active":"inactive")),o.sonarrWebhook.stats?(a.classList.remove("hidden"),document.getElementById("sonarr-events").textContent=o.sonarrWebhook.stats.eventsReceived??0,document.getElementById("sonarr-polls").textContent=o.sonarrWebhook.stats.pollsSkipped??0,document.getElementById("sonarr-last").textContent=$(o.sonarrWebhook.stats.lastWebhookTimestamp)):a.classList.add("hidden");const i=document.getElementById("radarr-status"),l=document.getElementById("enable-radarr-webhook"),m=document.getElementById("test-radarr-webhook"),u=document.getElementById("radarr-triggers"),c=document.getElementById("radarr-stats");i.textContent=o.radarrWebhook.enabled?"● Enabled":"○ Disabled",i.className="status-indicator "+(o.radarrWebhook.enabled?"enabled":"disabled"),o.radarrWebhook.enabled?(l.classList.add("hidden"),m.classList.remove("hidden"),u.classList.remove("hidden")):(l.classList.remove("hidden"),m.classList.add("hidden"),u.classList.add("hidden")),o.radarrWebhook.enabled&&(document.getElementById("radarr-onGrab").textContent=o.radarrWebhook.triggers.onGrab?"✓":"✗",document.getElementById("radarr-onGrab").className="trigger-value "+(o.radarrWebhook.triggers.onGrab?"active":"inactive"),document.getElementById("radarr-onDownload").textContent=o.radarrWebhook.triggers.onDownload?"✓":"✗",document.getElementById("radarr-onDownload").className="trigger-value "+(o.radarrWebhook.triggers.onDownload?"active":"inactive"),document.getElementById("radarr-onImport").textContent=o.radarrWebhook.triggers.onImport?"✓":"✗",document.getElementById("radarr-onImport").className="trigger-value "+(o.radarrWebhook.triggers.onImport?"active":"inactive"),document.getElementById("radarr-onUpgrade").textContent=o.radarrWebhook.triggers.onUpgrade?"✓":"✗",document.getElementById("radarr-onUpgrade").className="trigger-value "+(o.radarrWebhook.triggers.onUpgrade?"active":"inactive")),o.radarrWebhook.stats?(c.classList.remove("hidden"),document.getElementById("radarr-events").textContent=o.radarrWebhook.stats.eventsReceived??0,document.getElementById("radarr-polls").textContent=o.radarrWebhook.stats.pollsSkipped??0,document.getElementById("radarr-last").textContent=$(o.radarrWebhook.stats.lastWebhookTimestamp)):c.classList.add("hidden");const g=document.getElementById("ombi-status"),r=document.getElementById("enable-ombi-webhook"),d=document.getElementById("test-ombi-webhook"),p=document.getElementById("ombi-triggers"),h=document.getElementById("ombi-stats");g.textContent=o.ombiWebhook.enabled?"● Enabled":"○ Disabled",g.className="status-indicator "+(o.ombiWebhook.enabled?"enabled":"disabled"),o.ombiWebhook.enabled?(r.classList.add("hidden"),d.classList.remove("hidden"),p.classList.remove("hidden")):(r.classList.remove("hidden"),d.classList.add("hidden"),p.classList.add("hidden")),o.ombiWebhook.enabled&&(document.getElementById("ombi-requestAvailable").textContent=o.ombiWebhook.triggers.requestAvailable?"✓":"✗",document.getElementById("ombi-requestAvailable").className="trigger-value "+(o.ombiWebhook.triggers.requestAvailable?"active":"inactive"),document.getElementById("ombi-requestApproved").textContent=o.ombiWebhook.triggers.requestApproved?"✓":"✗",document.getElementById("ombi-requestApproved").className="trigger-value "+(o.ombiWebhook.triggers.requestApproved?"active":"inactive"),document.getElementById("ombi-requestDeclined").textContent=o.ombiWebhook.triggers.requestDeclined?"✓":"✗",document.getElementById("ombi-requestDeclined").className="trigger-value "+(o.ombiWebhook.triggers.requestDeclined?"active":"inactive"),document.getElementById("ombi-requestPending").textContent=o.ombiWebhook.triggers.requestPending?"✓":"✗",document.getElementById("ombi-requestPending").className="trigger-value "+(o.ombiWebhook.triggers.requestPending?"active":"inactive"),document.getElementById("ombi-requestProcessing").textContent=o.ombiWebhook.triggers.requestProcessing?"✓":"✗",document.getElementById("ombi-requestProcessing").className="trigger-value "+(o.ombiWebhook.triggers.requestProcessing?"active":"inactive")),o.ombiWebhook.stats?(h.classList.remove("hidden"),document.getElementById("ombi-events").textContent=o.ombiWebhook.stats.eventsReceived??0,document.getElementById("ombi-polls").textContent=o.ombiWebhook.stats.pollsSkipped??0,document.getElementById("ombi-last").textContent=$(o.ombiWebhook.stats.lastWebhookTimestamp)):h.classList.add("hidden")}async function yt(){E(!0);try{const e=await Ge();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{E(!1)}}async function vt(){E(!0);try{const e=await ze();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{E(!1)}}async function Et(){E(!0);try{const e=await Je();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{E(!1)}}async function kt(){E(!0);try{const e=await Qe();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{E(!1)}}async function St(){E(!0);try{const e=await Ke();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{E(!1)}}async function wt(){E(!0);try{const e=await Xe();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{E(!1)}}function E(e){o.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"),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 we()):t&&t.classList.add("hidden"),ie(),o.statusRefreshHandle&&clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=setInterval(ie,Me)}function It(){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 ie(){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 n=await Ve();n.success?(console.log("[Status] Got status data, rendering..."),Bt(n.data,e)):(console.error("[Status] API returned error:",n.error),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

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

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

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

")}}}function Bt(e,t){var v,x,k,J,Q,K,X,V,Y,Z,ee,te,se;console.log("[Status] renderStatusPanel called with data:",e?"yes":"no","keys:",e?Object.keys(e):"none");const s=e.server,n=Math.floor(s.uptimeSeconds/3600),a=Math.floor(s.uptimeSeconds%3600/60),i=s.uptimeSeconds%60,l=`${n}h ${a}m ${i}s`,m=(e.cache.totalSizeBytes/1024).toFixed(1);let u=`

Server Status

@@ -13,30 +13,32 @@ This will:
Server
-
Uptime${c}
-
Node${N(s.nodeVersion)}
+
Uptime${l}
+
Node${R(s.nodeVersion)}
Memory (RSS)${s.memoryUsageMB} MB
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
-
Data Refresh
`;const l=e.polling.intervalMs,i=(e.clients||[]).filter(g=>g.type==="sse");e.polling.enabled?d+=`
Background poll${l/1e3}s
`:d+='
Background pollDisabled
';const u=i.length>0?'SSE push':e.polling.enabled?"Background":"On-demand (idle)";d+=`
Delivery mode${u}
`,d+=`
SSE clients${i.length}
`;for(const g of i){const C=Math.round((Date.now()-g.connectedAt)/1e3);d+=`
${N(g.user)}connected ${C}s ago
`}if(d+="
",n.isAdmin&&e.webhooks){const g=e.webhooks,C=(E=g.sonarr)!=null&&E.enabled?"●":"○",w=(T=g.radarr)!=null&&T.enabled?"●":"○",I=((k=g.sonarr)==null?void 0:k.eventsReceived)||0,ye=((O=g.radarr)==null?void 0:O.eventsReceived)||0,ve=((j=g.sonarr)==null?void 0:j.pollsSkipped)||0,Ee=((_=g.radarr)==null?void 0:_.pollsSkipped)||0;d+=` +
Data Refresh
`;const c=e.polling.intervalMs,r=(e.clients||[]).filter(f=>f.type==="sse");e.polling.enabled?u+=`
Background poll${c/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 f of r){const I=Math.round((Date.now()-f.connectedAt)/1e3);u+=`
${R(f.user)}connected ${I}s ago
`}if(u+="
",o.isAdmin&&e.webhooks){const f=e.webhooks,I=(v=f.sonarr)!=null&&v.enabled?"●":"○",S=(x=f.radarr)!=null&&x.enabled?"●":"○",B=(k=f.ombi)!=null&&k.enabled?"●":"○",Te=((J=f.sonarr)==null?void 0:J.eventsReceived)||0,xe=((Q=f.radarr)==null?void 0:Q.eventsReceived)||0,Ne=((K=f.ombi)==null?void 0:K.eventsReceived)||0,Re=((X=f.sonarr)==null?void 0:X.pollsSkipped)||0,De=((V=f.radarr)==null?void 0:V.pollsSkipped)||0,Ae=((Y=f.ombi)==null?void 0:Y.pollsSkipped)||0;u+=`
Webhooks
-
Sonarr${C} ${(G=g.sonarr)!=null&&G.enabled?"Enabled":"Disabled"}
-
Radarr${w} ${(z=g.radarr)!=null&&z.enabled?"Enabled":"Disabled"}
-
EventsS:${I} R:${ye}
-
Polls skippedS:${ve} R:${Ee}
-
`}const p=e.polling.lastPoll;if(p){const g=Math.round((Date.now()-new Date(p.timestamp).getTime())/1e3);d+=` +
Sonarr${I} ${(Z=f.sonarr)!=null&&Z.enabled?"Enabled":"Disabled"}
+
Radarr${S} ${(ee=f.radarr)!=null&&ee.enabled?"Enabled":"Disabled"}
+
Ombi${B} ${(te=f.ombi)!=null&&te.enabled?"Enabled":"Disabled"}
+
EventsS:${Te} R:${xe} O:${Ne}
+
Polls skippedS:${Re} R:${De} O:${Ae}
+
`}const p=e.polling.lastPoll;if(p){const f=Math.round((Date.now()-new Date(p.timestamp).getTime())/1e3);u+=`
-
Last Poll (${p.totalMs}ms total, ${g}s ago)
-
`;const C=p.tasks.reduce((w,I)=>Math.max(w,I.ms),1);for(const w of p.tasks){const I=Math.max(2,w.ms/C*100);d+=` +
Last Poll (${p.totalMs}ms total, ${f}s ago)
+
`;const I=p.tasks.reduce((S,B)=>Math.max(S,B.ms),1);for(const S of p.tasks){const B=Math.max(2,S.ms/I*100);u+=`
- ${N(w.label)} -
- ${w.ms}ms -
`}d+="
"}d+=` + ${R(S.label)} +
+ ${S.ms}ms +
`}u+=""}u+=`
Cache (${e.cache.entryCount} entries, ${m} KB)
- `;for(const g of e.cache.entries){const C=g.sizeBytes>1024?(g.sizeBytes/1024).toFixed(1)+" KB":g.sizeBytes+" B",w=g.expired?'expired':(g.ttlRemainingMs/1e3).toFixed(0)+"s",I=g.itemCount!==null?g.itemCount:"—";d+=``}d+="
KeyItemsSizeTTL
${N(g.key)}${I}${C}${w}
";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(J=y==null?void 0:y.children)==null?void 0:J.length,"HTML length:",d.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=d,console.log("[Status] HTML rendered, contentDiv innerHTML length:",h.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",ut),t.querySelectorAll(".timing-bar[data-w]").forEach(g=>{g.style.width=g.dataset.w+"%"})}function N(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function ht(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function pt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function R(e){return new Promise(t=>{const s=Date.now()-(e||0),o=Math.max(0,ke-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},o)})}async function ft(){const e=Date.now();try{(await Ce()).authenticated?(pe(),fe(),H(),await R(e)):(await R(e),F())}catch(t){console.error("Authentication check failed:",t),await R(e),F()}}async function gt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,o=document.getElementById("remember-me").checked;try{const a=await Ie(t,s,o);if(a.success){await ht(),pt(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),pe(),fe();const r=Date.now();H(),await R(r)}else Z(a.error||"Login failed")}catch(a){Z("Login failed. Please try again."),console.error(a)}}async function bt(){try{me(),de(),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null),await Be(),n.currentUser=null,Ye(),F()}catch(e){console.error("Logout failed:",e)}}function F(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),yt()}function pe(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=n.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");n.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const o=document.getElementById("history-days");o&&(o.value=n.historyDays),Ve()}function Z(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function yt(){document.getElementById("login-error").classList.add("hidden")}function vt(){document.getElementById("error-message").classList.add("hidden")}function fe(){document.getElementById("loading").classList.remove("hidden")}function Et(){document.getElementById("loading").classList.add("hidden")}function kt(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),o=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ee(!0)),o&&o.addEventListener("click",()=>ee(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",M),M())}function M(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",n.downloadClients.forEach((t,s)=>{const o=document.createElement("div");o.className="filter-item",o.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.id=`client-${s}`,a.checked=n.selectedDownloadClients.includes(s),a.addEventListener("change",()=>wt(s));const r=document.createElement("label");r.htmlFor=`client-${s}`,r.textContent=t.name||`${t.type} (${t.id})`,o.appendChild(a),o.appendChild(r),e.appendChild(o)}),ge())}function wt(e){const t=n.selectedDownloadClients.indexOf(e);t>-1?n.selectedDownloadClients.splice(t,1):n.selectedDownloadClients.push(e),re(n.selectedDownloadClients),ge(),W()}function ee(e){e?n.selectedDownloadClients=n.downloadClients.map((t,s)=>s):n.selectedDownloadClients=[],re(n.selectedDownloadClients),M(),W()}function ge(){const e=document.getElementById("download-client-selected-text");if(e)if(n.selectedDownloadClients.length===0)e.textContent="All clients";else if(n.selectedDownloadClients.length===n.downloadClients.length)e.textContent="All clients";else{const t=n.selectedDownloadClients.map(s=>{var o,a;return((o=n.downloadClients[s])==null?void 0:o.name)||((a=n.downloadClients[s])==null?void 0:a.type)||""}).filter(Boolean);t.length===1?e.textContent=t[0]:e.textContent=`${n.selectedDownloadClients.length} clients`}}function be(e){return e?e.available?"available":e.denied?"denied":e.approved?"approved":e.requested?"pending":"unknown":"unknown"}function St(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return s.includes("all")?e:e.filter(o=>s.includes(o.mediaType))}function Ct(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return e.filter(o=>s.includes(be(o)))}function It(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(o=>(o.title||"").toLowerCase().includes(s))}function Bt(e,t){const s=[...e];switch(t){case"requestedDate_asc":return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0,c=a.requestedDate?new Date(a.requestedDate).getTime():0;return r-c});case"title_asc":return s.sort((o,a)=>(o.title||"").localeCompare(a.title||""));case"title_desc":return s.sort((o,a)=>(a.title||"").localeCompare(o.title||""));case"requestedDate_desc":default:return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0;return(a.requestedDate?new Date(a.requestedDate).getTime():0)-r})}}function Lt(e,{types:t,statuses:s,sort:o,search:a}={}){let r=[...e];return r=St(r,t),r=Ct(r,s),r=It(r,a),r=Bt(r,o),r}function qt(e){return e?e.requestedUser&&typeof e.requestedUser=="object"?e.requestedUser.alias||e.requestedUser.userAlias||e.requestedUser.userName||e.requestedUser.normalizedUserName||e.requestedByAlias||"":e.requestedUser||e.requestedByAlias||"":""}function S(){const e=document.getElementById("requests-list"),t=document.getElementById("no-requests");if(!e)return;const s=n.ombiRequests||{movie:[],tv:[]},o=[...s.movie.map(r=>({...r,mediaType:"movie"})),...s.tv.map(r=>({...r,mediaType:"tv"}))],a=Lt(o,{types:n.selectedRequestTypes,statuses:n.selectedRequestStatuses,sort:n.requestSortMode,search:n.requestSearchQuery});if(e.innerHTML="",a.length===0){if(t){t.style.display="block";const r=t.querySelector("p");if(r){const c=o.length>0;r.textContent=c?"No requests match your filters.":"No requests found."}}return}t&&(t.style.display="none"),a.forEach(r=>{const c=Tt(r);e.appendChild(c)})}function Tt(e){if(!e){const l=document.createElement("div");return l.className="request-card",l.textContent="Invalid request data",l}const t=document.createElement("div");t.className="request-card";const s=document.createElement("span");s.className=`request-type-icon ${e.mediaType||""}`,s.textContent=e.mediaType==="movie"?"🎬":"📺";const o=document.createElement("div");o.className="request-content";const a=document.createElement("div");a.className="request-title",a.textContent=e.title||"Unknown Title";const r=document.createElement("div");r.className="request-meta";const c=xt(e);if(r.appendChild(c),e.year){const l=document.createElement("span");l.className="request-year",l.textContent=e.year,r.appendChild(l)}const m=qt(e);if(m){const l=document.createElement("span");l.className="request-user",l.textContent=`Requested by: ${m}`,r.appendChild(l)}if(e.quality){const l=document.createElement("span");l.className="request-quality",l.textContent=e.quality,r.appendChild(l)}o.appendChild(a),o.appendChild(r);const d=document.createElement("div");if(d.className="request-actions",n.ombiBaseUrl&&e.theMovieDbId){const l=document.createElement("a");l.className="request-link ombi-link",l.href=`${n.ombiBaseUrl}/details/${e.mediaType||"movie"}/${e.theMovieDbId}`,l.target="_blank",l.title="View in Ombi";const f=document.createElement("img");f.src="/images/ombi.svg",f.alt="Ombi",f.className="request-icon",l.appendChild(f),d.appendChild(l)}return t.appendChild(s),t.appendChild(o),t.appendChild(d),t}function xt(e){const t=document.createElement("span");t.className="request-status-badge";const s=be(e),o={available:"Available",denied:`Denied: ${e.deniedReason||"No reason"}`,approved:"Approved",pending:"Pending",unknown:"Unknown"};return t.classList.add(s),t.textContent=o[s]||"Unknown",t}function Nt(){const e=document.getElementById("request-type-filter-btn"),t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-select-all"),o=document.getElementById("request-type-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>te(!0)),o==null||o.addEventListener("click",()=>te(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Rt(c,r.checked)})}),U()}function te(e){const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestTypes=e?o:[],le(n.selectedRequestTypes),U(),S()}function Rt(e,t){const s=n.selectedRequestTypes.indexOf(e);t&&s===-1?n.selectedRequestTypes.push(e):!t&&s>-1&&n.selectedRequestTypes.splice(s,1),le(n.selectedRequestTypes),U(),S()}function U(){const e=document.getElementById("request-type-selected-text");if(!e)return;const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestTypes.includes(a)}),n.selectedRequestTypes.length===0||n.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=n.selectedRequestTypes.length}function Dt(){const e=document.getElementById("request-status-filter-btn"),t=document.getElementById("request-status-filter-dropdown"),s=document.getElementById("request-status-select-all"),o=document.getElementById("request-status-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>se(!0)),o==null||o.addEventListener("click",()=>se(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;At(c,r.checked)})}),P()}function se(e){const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestStatuses=e?o:[],ce(n.selectedRequestStatuses),P(),S()}function At(e,t){const s=n.selectedRequestStatuses.indexOf(e);t&&s===-1?n.selectedRequestStatuses.push(e):!t&&s>-1&&n.selectedRequestStatuses.splice(s,1),ce(n.selectedRequestStatuses),P(),S()}function P(){const e=document.getElementById("request-status-selected-text");if(!e)return;const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestStatuses.includes(a)}),n.selectedRequestStatuses.length===0||n.selectedRequestStatuses.length===s.length?e.textContent="All":e.textContent=n.selectedRequestStatuses.length}function Ft(){const e=document.getElementById("request-sort-select");e&&(e.value=n.requestSortMode,e.addEventListener("change",t=>{n.requestSortMode=t.target.value,Qe(n.requestSortMode),S()}))}function Mt(){const e=document.getElementById("request-search-input");if(!e)return;e.value=n.requestSearchQuery;let t;e.addEventListener("input",s=>{clearTimeout(t),t=setTimeout(()=>{n.requestSearchQuery=s.target.value,Ke(n.requestSearchQuery),S()},200)})}function $t(){document.addEventListener("click",e=>{const t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-filter-btn"),o=document.getElementById("request-status-filter-dropdown"),a=document.getElementById("request-status-filter-btn");t&&!t.contains(e.target)&&e.target!==s&&!(s!=null&&s.contains(e.target))&&t.classList.remove("open"),o&&!o.contains(e.target)&&e.target!==a&&!(a!=null&&a.contains(e.target))&&o.classList.remove("open")})}function Wt(){Nt(),Dt(),Ft(),Mt(),$t(),document.addEventListener("ombiRequestsUpdated",()=>{S()})}(function(){const t=ie();t&&document.documentElement.setAttribute("data-theme",t)})();function Ht(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const s=ie()==="dark"?"light":"dark";Ut(s)})}function Ut(e){document.documentElement.setAttribute("data-theme",e),ze(e)}function Pt(){const e=document.querySelector('[data-tab="downloads"]'),t=document.querySelector('[data-tab="requests"]'),s=document.querySelector('[data-tab="history"]');if(!e||!s)return;const o=Je();L(o==="requests"?"requests":o==="history"?"history":"downloads"),e.addEventListener("click",()=>L("downloads")),t&&t.addEventListener("click",()=>L("requests")),s.addEventListener("click",()=>L("history"))}function L(e){const t=document.querySelector('[data-tab="downloads"]'),s=document.querySelector('[data-tab="requests"]'),o=document.querySelector('[data-tab="history"]'),a=document.getElementById("tab-downloads"),r=document.getElementById("tab-requests"),c=document.getElementById("tab-history");t&&t.classList.remove("active"),s&&s.classList.remove("active"),o&&o.classList.remove("active"),a&&a.classList.add("hidden"),r&&r.classList.add("hidden"),c&&c.classList.add("hidden"),e==="downloads"?(t&&t.classList.add("active"),a&&a.classList.remove("hidden"),A("downloads")):e==="requests"?(s&&s.classList.add("active"),r&&r.classList.remove("hidden"),A("requests"),S()):e==="history"&&(o&&o.classList.add("active"),c&&c.classList.remove("hidden"),A("history"),x())}function Ot(){L("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",gt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",bt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>et(r.target.checked));const o=document.getElementById("status-btn");o&&o.addEventListener("click",dt);const a=document.getElementById("home-btn");a&&a.addEventListener("click",Ot),Ht(),Pt(),kt(),Wt(),Xe(),tt(),Te().then(r=>{const c=document.getElementById("app-version");c&&r&&(c.textContent="v"+r)}),ft()}); + `;for(const f of e.cache.entries){const I=f.sizeBytes>1024?(f.sizeBytes/1024).toFixed(1)+" KB":f.sizeBytes+" B",S=f.expired?'expired':(f.ttlRemainingMs/1e3).toFixed(0)+"s",B=f.itemCount!==null?f.itemCount:"—";u+=`${R(f.key)}${B}${I}${S}`}u+="";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(se=y==null?void 0:y.children)==null?void 0:se.length,"HTML length:",u.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=u,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",It),t.querySelectorAll(".timing-bar[data-w]").forEach(f=>{f.style.width=f.dataset.w+"%"})}function R(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Lt(){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 qt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function D(e){return new Promise(t=>{const s=Date.now()-(e||0),n=Math.max(0,Fe-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const l=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(l),a.classList.add("hidden"),t()},{once:!0})},n)})}async function Tt(){const e=Date.now();try{(await Ue()).authenticated?(Ce(),Ie(),_(),await D(e)):(await D(e),W())}catch(t){console.error("Authentication check failed:",t),await D(e),W()}}async function xt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,n=document.getElementById("remember-me").checked;try{const a=await We(t,s,n);if(a.success){await Lt(),qt(),await new Promise(l=>requestAnimationFrame(()=>requestAnimationFrame(l))),Ce(),Ie();const i=Date.now();_(),await D(i)}else le(a.error||"Login failed")}catch(a){le("Login failed. Please try again."),console.error(a)}}async function Nt(){try{Se(),Ee(),o.statusRefreshHandle&&(clearInterval(o.statusRefreshHandle),o.statusRefreshHandle=null),await He(),o.currentUser=null,mt(),W()}catch(e){console.error("Logout failed:",e)}}function W(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),Rt()}function Ce(){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 s=document.getElementById("admin-controls");o.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const n=document.getElementById("history-days");n&&(n.value=o.historyDays),ut()}function le(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function Rt(){document.getElementById("login-error").classList.add("hidden")}function Dt(){document.getElementById("error-message").classList.add("hidden")}function Ie(){document.getElementById("loading").classList.remove("hidden")}function At(){document.getElementById("loading").classList.add("hidden")}function Ft(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),n=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ce(!0)),n&&n.addEventListener("click",()=>ce(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",H),H())}function H(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",o.downloadClients.forEach((t,s)=>{const n=document.createElement("div");n.className="download-client-option",n.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.className="download-client-checkbox",a.id=`client-${s}`,a.checked=o.selectedDownloadClients.includes(s),a.addEventListener("change",()=>$t(s));const i=document.createElement("span");i.className="download-client-icon";const l=document.createElement("img");l.src=`/images/clients/${t.type}.svg`,l.alt=`${t.name||t.type} icon`,l.onerror=()=>{i.textContent=t.type.charAt(0).toUpperCase(),i.classList.add("fallback")},i.appendChild(l);const m=document.createElement("label");m.className="download-client-option-label",m.htmlFor=`client-${s}`,m.textContent=t.name||`${t.type} (${t.id})`;const u=document.createElement("span");u.className="download-client-type",u.textContent=t.type,n.appendChild(a),n.appendChild(i),n.appendChild(m),n.appendChild(u),e.appendChild(n)}),Be())}function $t(e){const t=o.selectedDownloadClients.indexOf(e);t>-1?o.selectedDownloadClients.splice(t,1):o.selectedDownloadClients.push(e),ge(o.selectedDownloadClients),Be(),j()}function ce(e){e?o.selectedDownloadClients=o.downloadClients.map((t,s)=>s):o.selectedDownloadClients=[],ge(o.selectedDownloadClients),H(),j()}function Be(){const e=document.getElementById("download-client-selected-text");if(e)if(o.selectedDownloadClients.length===0)e.textContent="All clients";else if(o.selectedDownloadClients.length===o.downloadClients.length)e.textContent="All clients";else{const t=o.selectedDownloadClients.map(s=>{var n,a;return((n=o.downloadClients[s])==null?void 0:n.name)||((a=o.downloadClients[s])==null?void 0:a.type)||""}).filter(Boolean);t.length===1?e.textContent=t[0]:e.textContent=`${o.selectedDownloadClients.length} clients`}}function Le(e){return e?e.available?"available":e.denied?"denied":e.approved?"approved":e.requested?"pending":"unknown":"unknown"}function Mt(e,t){if(!t||t.length===0)return e;const s=t.map(n=>n.toLowerCase());return s.includes("all")?e:e.filter(n=>s.includes(n.mediaType))}function Ut(e,t){if(!t||t.length===0)return e;const s=t.map(n=>n.toLowerCase());return e.filter(n=>s.includes(Le(n)))}function Wt(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(n=>(n.title||"").toLowerCase().includes(s))}function Ht(e,t){const s=[...e];switch(t){case"requestedDate_asc":return s.sort((n,a)=>{const i=n.requestedDate?new Date(n.requestedDate).getTime():0,l=a.requestedDate?new Date(a.requestedDate).getTime():0;return i-l});case"title_asc":return s.sort((n,a)=>(n.title||"").localeCompare(a.title||""));case"title_desc":return s.sort((n,a)=>(a.title||"").localeCompare(n.title||""));case"requestedDate_desc":default:return s.sort((n,a)=>{const i=n.requestedDate?new Date(n.requestedDate).getTime():0;return(a.requestedDate?new Date(a.requestedDate).getTime():0)-i})}}function Ot(e,{types:t,statuses:s,sort:n,search:a}={}){let i=[...e];return i=Mt(i,t),i=Ut(i,s),i=Wt(i,a),i=Ht(i,n),i}function O(e){if(!e)return"";const t=e.requestedUser||e.RequestedUser||e.user||e.User||e.requestedBy||e.RequestedBy||e.ombiUser||e.OmbiUser||e.requestedByUser||e.RequestedByUser;if(t&&typeof t=="object"){const n=t.alias||t.Alias||t.userAlias||t.UserAlias||t.userName||t.UserName||t.normalizedUserName||t.NormalizedUserName||t.displayName||t.DisplayName||t.email||t.Email;if(n)return n}if(t&&typeof t=="string")return t;const s=e.requestedByAlias||e.RequestedByAlias||e.requestedByUsername||e.RequestedByUsername||e.requester||e.Requester||e.requestedByEmail||e.RequestedByEmail;if(s)return s;if(Array.isArray(e.seasons))for(const n of e.seasons){const a=O(n);if(a)return a}if(Array.isArray(e.childRequests))for(const n of e.childRequests){const a=O(n);if(a)return a}return""}function C(){const e=document.getElementById("requests-list"),t=document.getElementById("no-requests");if(!e)return;const s=o.ombiRequests||{movie:[],tv:[]},n=[...s.movie.map(i=>({...i,mediaType:"movie"})),...s.tv.map(i=>({...i,mediaType:"tv"}))],a=Ot(n,{types:o.selectedRequestTypes,statuses:o.selectedRequestStatuses,sort:o.requestSortMode,search:o.requestSearchQuery});if(e.innerHTML="",a.length===0){if(t){t.style.display="block";const i=t.querySelector("p");if(i){const l=n.length>0;i.textContent=l?"No requests match your filters.":"No requests found."}}return}t&&(t.style.display="none"),a.forEach(i=>{const l=Pt(i);e.appendChild(l)})}function Pt(e){if(!e){const r=document.createElement("div");return r.className="request-card",r.textContent="Invalid request data",r}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 n=document.createElement("div");n.className="request-content";const a=document.createElement("div");a.className="request-title",a.textContent=e.title||"Unknown Title";const i=document.createElement("div");i.className="request-meta";const l=jt(e);if(i.appendChild(l),e.year){const r=document.createElement("span");r.className="request-year",r.textContent=e.year,i.appendChild(r)}const m=O(e),u=document.createElement("span");u.className="request-user",m?u.textContent=`Requested by: ${m}`:(u.textContent="Requested by: Unknown (Ombi)",u.title="No user information received from Ombi",u.style.cursor="help",u.style.textDecoration="underline dotted"),i.appendChild(u);const c=e.requestedDate||e.RequestedDate||e.date||e.Date;if(c){const r=document.createElement("span");r.className="request-date";try{const d=new Date(c);if(isNaN(d.getTime()))r.textContent=`Date: ${c}`;else{const p=d.getFullYear(),h=String(d.getMonth()+1).padStart(2,"0"),y=String(d.getDate()).padStart(2,"0"),b=String(d.getHours()).padStart(2,"0"),v=String(d.getMinutes()).padStart(2,"0");r.textContent=`Date: ${p}-${h}-${y} ${b}:${v}`}}catch{r.textContent=`Date: ${c}`}i.appendChild(r)}if(e.quality){const r=document.createElement("span");r.className="request-quality",r.textContent=e.quality,i.appendChild(r)}n.appendChild(a),n.appendChild(i);const g=document.createElement("div");if(g.className="request-actions",o.ombiBaseUrl&&e.theMovieDbId){const r=document.createElement("a");r.className="request-link ombi-link",r.href=`${o.ombiBaseUrl}/details/${e.mediaType||"movie"}/${e.theMovieDbId}`,r.target="_blank",r.title="View in Ombi";const d=document.createElement("img");d.src="/images/ombi.svg",d.alt="Ombi",d.className="request-icon",r.appendChild(d),g.appendChild(r)}if(o.isAdmin&&e.arrLink){const r=document.createElement("a");r.className=`request-link ${e.arrType}-link`,r.href=e.arrLink,r.target="_blank",r.title=`View in ${e.arrType==="sonarr"?"Sonarr":"Radarr"}`;const d=document.createElement("img");d.src=e.arrType==="sonarr"?"/images/sonarr.svg":"/images/radarr.svg",d.alt=e.arrType==="sonarr"?"Sonarr":"Radarr",d.className="request-icon",r.appendChild(d),g.appendChild(r)}return t.appendChild(s),t.appendChild(n),t.appendChild(g),t}function jt(e){const t=document.createElement("span");t.className="request-status-badge";const s=Le(e),n={available:"Available",denied:`Denied: ${e.deniedReason||"No reason"}`,approved:"Approved",pending:"Pending",unknown:"Unknown"};return t.classList.add(s),t.textContent=n[s]||"Unknown",t}function _t(){const e=document.getElementById("request-type-filter-btn"),t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-select-all"),n=document.getElementById("request-type-deselect-all");if(!e||!t)return;e.addEventListener("click",i=>{i.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>de(!0)),n==null||n.addEventListener("click",()=>de(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(i=>{i.addEventListener("change",()=>{const l=i.closest(".request-filter-option").dataset.value;Gt(l,i.checked)})}),G()}function de(e){const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox"),n=[];s.forEach(a=>{a.checked=e,e&&n.push(a.closest(".request-filter-option").dataset.value)}),o.selectedRequestTypes=e?n:[],ye(o.selectedRequestTypes),G(),C()}function Gt(e,t){const s=o.selectedRequestTypes.indexOf(e);t&&s===-1?o.selectedRequestTypes.push(e):!t&&s>-1&&o.selectedRequestTypes.splice(s,1),ye(o.selectedRequestTypes),G(),C()}function G(){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(n=>{const a=n.closest(".request-filter-option").dataset.value;n.checked=o.selectedRequestTypes.includes(a)}),o.selectedRequestTypes.length===0||o.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=o.selectedRequestTypes.length}function zt(){const e=document.getElementById("request-status-filter-btn"),t=document.getElementById("request-status-filter-dropdown"),s=document.getElementById("request-status-select-all"),n=document.getElementById("request-status-deselect-all");if(!e||!t)return;e.addEventListener("click",i=>{i.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>ue(!0)),n==null||n.addEventListener("click",()=>ue(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(i=>{i.addEventListener("change",()=>{const l=i.closest(".request-filter-option").dataset.value;Jt(l,i.checked)})}),z()}function ue(e){const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox"),n=[];s.forEach(a=>{a.checked=e,e&&n.push(a.closest(".request-filter-option").dataset.value)}),o.selectedRequestStatuses=e?n:[],ve(o.selectedRequestStatuses),z(),C()}function Jt(e,t){const s=o.selectedRequestStatuses.indexOf(e);t&&s===-1?o.selectedRequestStatuses.push(e):!t&&s>-1&&o.selectedRequestStatuses.splice(s,1),ve(o.selectedRequestStatuses),z(),C()}function z(){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(n=>{const a=n.closest(".request-filter-option").dataset.value;n.checked=o.selectedRequestStatuses.includes(a)}),o.selectedRequestStatuses.length===0||o.selectedRequestStatuses.length===s.length?e.textContent="All":e.textContent=o.selectedRequestStatuses.length}function Qt(){const e=document.getElementById("request-sort-select");e&&(e.value=o.requestSortMode,e.addEventListener("change",t=>{o.requestSortMode=t.target.value,lt(o.requestSortMode),C()}))}function Kt(){const e=document.getElementById("request-search-input");if(!e)return;e.value=o.requestSearchQuery;let t;e.addEventListener("input",s=>{clearTimeout(t),t=setTimeout(()=>{o.requestSearchQuery=s.target.value,ct(o.requestSearchQuery),C()},200)})}function Xt(){document.addEventListener("click",e=>{const t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-filter-btn"),n=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"),n&&!n.contains(e.target)&&e.target!==a&&!(a!=null&&a.contains(e.target))&&n.classList.remove("open")})}function Vt(){_t(),zt(),Qt(),Kt(),Xt(),document.addEventListener("ombiRequestsUpdated",()=>{C()})}(function(){const t=be()||"light";document.documentElement.setAttribute("data-theme",t)})();function Yt(){const e=document.querySelectorAll(".theme-btn"),t=be()||"light";e.forEach(s=>{s.getAttribute("data-theme")===t?s.classList.add("active"):s.classList.remove("active"),s.addEventListener("click",()=>{const n=s.getAttribute("data-theme");n&&Zt(n)})})}function Zt(e){document.documentElement.setAttribute("data-theme",e),rt(e),document.querySelectorAll(".theme-btn").forEach(s=>{s.getAttribute("data-theme")===e?s.classList.add("active"):s.classList.remove("active")})}function es(){const e=document.querySelector('[data-tab="downloads"]'),t=document.querySelector('[data-tab="requests"]'),s=document.querySelector('[data-tab="history"]');if(!e||!s)return;const n=it();q(n==="requests"?"requests":n==="history"?"history":"downloads"),e.addEventListener("click",()=>q("downloads")),t&&t.addEventListener("click",()=>q("requests")),s.addEventListener("click",()=>q("history"))}function q(e){const t=document.querySelector('[data-tab="downloads"]'),s=document.querySelector('[data-tab="requests"]'),n=document.querySelector('[data-tab="history"]'),a=document.getElementById("tab-downloads"),i=document.getElementById("tab-requests"),l=document.getElementById("tab-history");t&&t.classList.remove("active"),s&&s.classList.remove("active"),n&&n.classList.remove("active"),a&&a.classList.add("hidden"),i&&i.classList.add("hidden"),l&&l.classList.add("hidden"),e==="downloads"?(t&&t.classList.add("active"),a&&a.classList.remove("hidden"),M("downloads")):e==="requests"?(s&&s.classList.add("active"),i&&i.classList.remove("hidden"),M("requests"),C()):e==="history"&&(n&&n.classList.add("active"),l&&l.classList.remove("hidden"),M("history"),N())}function ts(){q("downloads")}const w=[],ss=20,ns=2e3,os=console.log,as=console.warn,F=console.error;let A=!1,me=!1,rs=null;function is(e){return e.map(t=>{if(t===null)return"null";if(t===void 0)return"undefined";if(t instanceof Error)return`${t.name}: ${t.message} +${t.stack||""}`;if(typeof t=="object")try{return JSON.stringify(t)}catch{return String(t)}return String(t)}).join(" ")}function U(e,t){const s=is(t);e==="info"?os.apply(console,t):e==="warn"?as.apply(console,t):e==="error"&&F.apply(console,t),!A&&(w.push({timestamp:new Date().toISOString(),level:e,message:s}),w.length>=ss&&qe())}async function qe(){if(w.length===0||A)return;A=!0;const e=[...w];w.length=0;try{const t=await fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0});t.ok||F.call(console,"[clientLogCapture] Ingestion server returned error status:",t.status)}catch(t){F.call(console,"[clientLogCapture] Ingestion post request failed:",t.message)}finally{A=!1}}function ls(){if(w.length===0)return;const e=[...w];w.length=0;try{const t=new Blob([JSON.stringify(e)],{type:"application/json"});navigator.sendBeacon("/api/debug/client-logs",t)}catch{try{fetch("/api/debug/client-logs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),keepalive:!0})}catch{}}}async function cs(){if(!me)try{const e=await fetch("/api/debug/status");if(!e.ok)return;const t=await e.json();t&&t.enabled===!0&&(console.log=(...s)=>U("info",s),console.warn=(...s)=>U("warn",s),console.error=(...s)=>U("error",s),rs=setInterval(qe,ns),window.addEventListener("beforeunload",ls),me=!0,console.log("[clientLogCapture] Browser console logging interceptor initialized successfully."))}catch(e){F.call(console,"[clientLogCapture] Check failed to start interceptor:",e.message)}}document.addEventListener("DOMContentLoaded",()=>{cs();const e=document.getElementById("login-form");e&&e.addEventListener("submit",xt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",Nt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",i=>pt(i.target.checked));const n=document.getElementById("status-btn");n&&n.addEventListener("click",Ct);const a=document.getElementById("home-btn");a&&a.addEventListener("click",ts),Yt(),es(),Ft(),Vt(),dt(),ft(),je().then(i=>{const l=document.getElementById("app-version");l&&i&&(l.textContent="v"+i)}),Tt()}); diff --git a/server/app.js b/server/app.js index dfdefb5..dadaecf 100644 --- a/server/app.js +++ b/server/app.js @@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) { * version: * type: string * description: sofarr version - * example: "1.7.21" + * example: "1.7.22" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/index.js b/server/index.js index e5e077f..ff84fa1 100644 --- a/server/index.js +++ b/server/index.js @@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads * version: * type: string * description: sofarr version - * example: "1.7.21" + * example: "1.7.22" */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), version }); diff --git a/server/openapi.yaml b/server/openapi.yaml index f0e1b8e..17d04a8 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.21 + version: 1.7.22 contact: name: sofarr license: diff --git a/server/routes/ombi.js b/server/routes/ombi.js index 7f232ce..7feafc0 100644 --- a/server/routes/ombi.js +++ b/server/routes/ombi.js @@ -120,6 +120,66 @@ router.get('/requests', requireAuth, async (req, res) => { const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll); const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll); + // Admin only: add Sonarr/Radarr lookup links + if (isAdmin) { + const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || []; + const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || []; + + // Fetch all series and movies in parallel to match + const [sonarrData, radarrData] = await Promise.all([ + Promise.all(sonarrRetrievers.map(async r => { + try { + const response = await require('axios').get(`${r.url}/api/v3/series`, { + headers: { 'X-Api-Key': r.apiKey } + }); + return { instance: r, series: response.data || [] }; + } catch { + return { instance: r, series: [] }; + } + })), + Promise.all(radarrRetrievers.map(async r => { + try { + const response = await require('axios').get(`${r.url}/api/v3/movie`, { + headers: { 'X-Api-Key': r.apiKey } + }); + return { instance: r, movies: response.data || [] }; + } catch { + return { instance: r, movies: [] }; + } + })) + ]); + + // For TV requests, find match in Sonarr + filteredTvRequests.forEach(req => { + const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId; + if (!tvdbId) return; + + for (const instData of sonarrData) { + const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10))); + if (match && match.titleSlug) { + req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`; + req.arrType = 'sonarr'; + break; + } + } + }); + + // For Movie requests, find match in Radarr + filteredMovieRequests.forEach(req => { + const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId; + if (!tmdbId) return; + + for (const instData of radarrData) { + const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId)); + if (match && match.titleSlug) { + req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`; + req.arrType = 'radarr'; + break; + } + } + }); + } + // Tag with mediaType and flatten for filtering/sorting const allRequests = [ ...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })), diff --git a/server/routes/webhook.js b/server/routes/webhook.js index fe8e523..f21dedc 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -180,7 +180,7 @@ function validateWebhookSecret(req) { * @param {string} serviceType - 'sonarr', 'radarr', or 'ombi' * @param {string} eventType - the eventType from the webhook payload */ -async function processWebhookEvent(serviceType, eventType) { +async function processWebhookEvent(serviceType, eventType, payload = null) { const affectsQueue = QUEUE_EVENTS.has(eventType); const affectsHistory = HISTORY_EVENTS.has(eventType); const affectsOmbi = OMBI_EVENTS.has(eventType); @@ -259,9 +259,66 @@ async function processWebhookEvent(serviceType, eventType) { const ombiInstances = getOmbiInstances(); if (affectsOmbi) { - // Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB - await new Promise(r => setTimeout(r, 2000)); - const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); + const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10); + const initialDelay = !isNaN(delayMs) ? delayMs : 2000; + logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`); + await new Promise(r => setTimeout(r, initialDelay)); + + const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null; + const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null; + + let ombiRequests = { movie: [], tv: [] }; + let foundAndValid = false; + const maxRetries = 3; + const retryDelayMs = 1500; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + if (attempt > 1) { + logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`); + await new Promise(r => setTimeout(r, retryDelayMs)); + } + + ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); + + if (!requestId) { + // If no requestId was provided in payload, we can't search specifically, so just accept the fetch + foundAndValid = true; + break; + } + + // Search in movie or tv lists + const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []); + // Also check both if mediaType not specified + const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])]; + + const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId)); + if (targetReq) { + const user = extractRequestedUser(targetReq); + if (user) { + logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`); + foundAndValid = true; + break; + } else { + logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`); + } + } else { + logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`); + } + } + + if (!foundAndValid && requestId) { + logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`); + // Try to log the raw target request if we found one + ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); + const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])]; + const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId)); + if (targetReq) { + logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`); + } else { + logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`); + } + } + cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`); } @@ -777,7 +834,7 @@ router.post('/ombi', webhookLimiter, (req, res) => { } // Background cache refresh + SSE broadcast (fire-and-forget) - processWebhookEvent('ombi', eventType).catch(err => { + processWebhookEvent('ombi', eventType, req.body).catch(err => { logToFile(`[Webhook] Ombi background refresh error: ${err.message}`); }); diff --git a/server/utils/ombiHelpers.js b/server/utils/ombiHelpers.js index 42cff9c..d605c9a 100644 --- a/server/utils/ombiHelpers.js +++ b/server/utils/ombiHelpers.js @@ -5,6 +5,8 @@ * not a string, so we need to extract the username from the object. */ +const { logToFile } = require('./logger'); + /** * Extracts the username from an Ombi request object. * Handles both the OmbiUser object format and legacy string format. @@ -15,19 +17,57 @@ function extractRequestedUser(request) { if (!request) return ''; - const requestedUser = request.requestedUser || request.RequestedUser; - - // Handle object format: OmbiStore.Entities.OmbiUser - if (requestedUser && typeof requestedUser === 'object') { - // Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias - return requestedUser.alias || requestedUser.Alias || - requestedUser.userAlias || requestedUser.UserAlias || - requestedUser.userName || requestedUser.UserName || - requestedUser.normalizedUserName || requestedUser.NormalizedUserName || - request.requestedByAlias || request.RequestedByAlias || ''; + // Try to locate a user object or string from various fields common to Ombi Movies and TV shows + const userSource = request.requestedUser || request.RequestedUser || + request.user || request.User || + request.requestedBy || request.RequestedBy || + request.ombiUser || request.OmbiUser || + request.requestedByUser || request.RequestedByUser; + + // If userSource is an object, extract key fields + if (userSource && typeof userSource === 'object') { + const username = userSource.alias || userSource.Alias || + userSource.userAlias || userSource.UserAlias || + userSource.userName || userSource.UserName || + userSource.normalizedUserName || userSource.NormalizedUserName || + userSource.displayName || userSource.DisplayName || + userSource.email || userSource.Email; + if (username) return username; } - // Handle string format (fallback for compatibility) - return requestedUser || request.requestedByAlias || request.RequestedByAlias || ''; + + // If userSource is a string and not an empty object/array + if (userSource && typeof userSource === 'string') { + return userSource; + } + + // Fallbacks on the request root level + const rootFallback = request.requestedByAlias || request.RequestedByAlias || + request.requestedByUsername || request.RequestedByUsername || + request.requester || request.Requester || + request.requestedByEmail || request.RequestedByEmail; + if (rootFallback) return rootFallback; + + // Check seasons / childRequests nested arrays (common for Ombi TV show requests) + if (Array.isArray(request.seasons)) { + for (const season of request.seasons) { + const seasonUser = extractRequestedUser(season); + if (seasonUser) return seasonUser; + } + } + + if (Array.isArray(request.childRequests)) { + for (const child of request.childRequests) { + const childUser = extractRequestedUser(child); + if (childUser) return childUser; + } + } + + // Add warning log when user extraction returns empty for non-empty requests + if (Object.keys(request).length > 0 && !request.notificationType) { + logToFile(`[Ombi] WARNING: User extraction failed for request: ${JSON.stringify(request)}`); + } + + return ''; } function filterRequestsByUser(requests, username, showAll) { diff --git a/tests/frontend/ui/requests.test.js b/tests/frontend/ui/requests.test.js new file mode 100644 index 0000000..9065fe9 --- /dev/null +++ b/tests/frontend/ui/requests.test.js @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +/** + * @vitest-environment jsdom + * Tests for client/src/ui/requests.js + * + * Verifies requests dashboard rendering, tooltips, dates, and deep links. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderRequests } from '../../../client/src/ui/requests.js'; +import { state } from '../../../client/src/state.js'; + +vi.mock('../../../client/src/state.js', () => { + return { + state: { + ombiRequests: { movie: [], tv: [] }, + selectedRequestTypes: ['movie', 'tv'], + selectedRequestStatuses: ['pending', 'approved', 'available', 'denied'], + requestSortMode: 'requestedDate_desc', + requestSearchQuery: '', + ombiBaseUrl: 'https://ombi.test', + isAdmin: false + } + }; +}); + +describe('requests rendering', () => { + let requestsList, noRequests; + + beforeEach(() => { + vi.clearAllMocks(); + + document.body.innerHTML = ` +
+ + `; + + requestsList = document.getElementById('requests-list'); + noRequests = document.getElementById('no-requests'); + + state.ombiRequests = { movie: [], tv: [] }; + state.isAdmin = false; + state.ombiBaseUrl = 'https://ombi.test'; + }); + + it('renders "No requests found." when request arrays are empty', () => { + renderRequests(); + + expect(requestsList.childNodes.length).toBe(0); + expect(noRequests.style.display).toBe('block'); + expect(noRequests.querySelector('p').textContent).toBe('No requests found.'); + }); + + it('renders request card with correctly formatted date, media type, and requester', () => { + state.ombiRequests = { + movie: [ + { + id: 101, + title: 'Movie Test', + year: '2026', + requestedUser: { alias: 'john_doe' }, + requestedDate: '2026-05-27T10:15:30.000Z', + quality: '1080p', + theMovieDbId: 555, + requested: true + } + ], + tv: [] + }; + + renderRequests(); + + expect(requestsList.childNodes.length).toBe(1); + const card = requestsList.childNodes[0]; + expect(card.querySelector('.request-title').textContent).toBe('Movie Test'); + expect(card.querySelector('.request-year').textContent).toBe('2026'); + expect(card.querySelector('.request-user').textContent).toBe('Requested by: john_doe'); + + // Check formatted date + const dateEl = card.querySelector('.request-date'); + expect(dateEl).toBeTruthy(); + expect(dateEl.textContent).toContain('Date: 2026-05-27'); + + // Check view in Ombi link + const ombiLink = card.querySelector('.ombi-link'); + expect(ombiLink).toBeTruthy(); + expect(ombiLink.href).toBe('https://ombi.test/details/movie/555'); + }); + + it('renders "Unknown (Ombi)" with tooltip when requester is missing', () => { + state.ombiRequests = { + movie: [], + tv: [ + { + id: 201, + title: 'TV Test No User', + requestedDate: '2026-05-27T12:00:00.000Z', + requested: true + } + ] + }; + + renderRequests(); + + expect(requestsList.childNodes.length).toBe(1); + const card = requestsList.childNodes[0]; + const userEl = card.querySelector('.request-user'); + expect(userEl).toBeTruthy(); + expect(userEl.textContent).toBe('Requested by: Unknown (Ombi)'); + expect(userEl.title).toBe('No user information received from Ombi'); + expect(userEl.style.textDecoration).toBe('underline dotted'); + }); + + it('does NOT render Sonarr/Radarr deep links for non-admin users', () => { + state.isAdmin = false; + state.ombiRequests = { + movie: [ + { + id: 101, + title: 'Movie Test', + theMovieDbId: 555, + arrLink: 'http://radarr:7878/movie/slug', + arrType: 'radarr', + requested: true + } + ], + tv: [] + }; + + renderRequests(); + + const card = requestsList.childNodes[0]; + expect(card.querySelector('.radarr-link')).toBeNull(); + }); + + it('renders Sonarr/Radarr deep links next to Ombi link for administrators', () => { + state.isAdmin = true; + state.ombiRequests = { + movie: [ + { + id: 101, + title: 'Movie Test', + theMovieDbId: 555, + arrLink: 'http://radarr:7878/movie/slug', + arrType: 'radarr', + requested: true + } + ], + tv: [ + { + id: 202, + title: 'TV Show Test', + theMovieDbId: 666, + arrLink: 'http://sonarr:8989/series/slug', + arrType: 'sonarr', + requested: true + } + ] + }; + + renderRequests(); + + expect(requestsList.childNodes.length).toBe(2); + + // Check Radarr link + const movieCard = requestsList.childNodes[0]; + const radarrLink = movieCard.querySelector('.radarr-link'); + expect(radarrLink).toBeTruthy(); + expect(radarrLink.href).toBe('http://radarr:7878/movie/slug'); + expect(radarrLink.title).toBe('View in Radarr'); + + // Check Sonarr link + const tvCard = requestsList.childNodes[1]; + const sonarrLink = tvCard.querySelector('.sonarr-link'); + expect(sonarrLink).toBeTruthy(); + expect(sonarrLink.href).toBe('http://sonarr:8989/series/slug'); + expect(sonarrLink.title).toBe('View in Sonarr'); + }); +}); diff --git a/tests/frontend/ui/theme.test.js b/tests/frontend/ui/theme.test.js new file mode 100644 index 0000000..aafdae4 --- /dev/null +++ b/tests/frontend/ui/theme.test.js @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +/** + * @vitest-environment jsdom + * Tests for client/src/ui/theme.js + * + * Verifies DOM actions for theme switcher button clicks, attributes, and storage calls. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { initThemeSwitcher, setTheme } from '../../../client/src/ui/theme.js'; +import * as storage from '../../../client/src/utils/storage.js'; + +vi.mock('../../../client/src/utils/storage.js', () => { + let store = {}; + return { + getTheme: vi.fn(() => store.theme || 'light'), + saveTheme: vi.fn((theme) => { store.theme = theme; }) + }; +}); + +describe('theme switcher', () => { + let lightBtn, darkBtn, monoBtn; + + beforeEach(() => { + vi.clearAllMocks(); + document.documentElement.removeAttribute('data-theme'); + + // Create mock theme buttons + document.body.innerHTML = ` +
+ + + +
+ `; + + lightBtn = document.querySelector('[data-theme="light"]'); + darkBtn = document.querySelector('[data-theme="dark"]'); + monoBtn = document.querySelector('[data-theme="mono"]'); + }); + + it('initThemeSwitcher sets active class based on saved theme on load', () => { + vi.spyOn(storage, 'getTheme').mockReturnValue('dark'); + + initThemeSwitcher(); + + expect(storage.getTheme).toHaveBeenCalled(); + expect(darkBtn.classList.contains('active')).toBe(true); + expect(lightBtn.classList.contains('active')).toBe(false); + expect(monoBtn.classList.contains('active')).toBe(false); + }); + + it('initThemeSwitcher defaults to light theme if no theme is saved', () => { + vi.spyOn(storage, 'getTheme').mockReturnValue(null); + + initThemeSwitcher(); + + expect(lightBtn.classList.contains('active')).toBe(true); + expect(darkBtn.classList.contains('active')).toBe(false); + }); + + it('clicking theme button switches the document theme and persists choice', () => { + initThemeSwitcher(); + + // Initial active button should be light + expect(lightBtn.classList.contains('active')).toBe(true); + + // Click Dark + darkBtn.click(); + + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(storage.saveTheme).toHaveBeenCalledWith('dark'); + expect(darkBtn.classList.contains('active')).toBe(true); + expect(lightBtn.classList.contains('active')).toBe(false); + + // Click Mono + monoBtn.click(); + + expect(document.documentElement.getAttribute('data-theme')).toBe('mono'); + expect(storage.saveTheme).toHaveBeenCalledWith('mono'); + expect(monoBtn.classList.contains('active')).toBe(true); + expect(darkBtn.classList.contains('active')).toBe(false); + }); + + it('setTheme directly sets document attribute and updates button classes if present', () => { + initThemeSwitcher(); // binds buttons + + setTheme('mono'); + + expect(document.documentElement.getAttribute('data-theme')).toBe('mono'); + expect(storage.saveTheme).toHaveBeenCalledWith('mono'); + expect(monoBtn.classList.contains('active')).toBe(true); + expect(lightBtn.classList.contains('active')).toBe(false); + }); +}); diff --git a/tests/unit/ombiHelpers.test.js b/tests/unit/ombiHelpers.test.js index d42708d..bdf5ed8 100644 --- a/tests/unit/ombiHelpers.test.js +++ b/tests/unit/ombiHelpers.test.js @@ -79,6 +79,57 @@ describe('ombiHelpers', () => { }; expect(extractRequestedUser(req)).toBe(''); }); + + it('returns userName from nested user object', () => { + const req = { user: { userName: 'user_val' } }; + expect(extractRequestedUser(req)).toBe('user_val'); + }); + + it('returns alias from nested requestedBy object', () => { + const req = { requestedBy: { alias: 'req_alias' } }; + expect(extractRequestedUser(req)).toBe('req_alias'); + }); + + it('returns normalizedUserName from nested ombiUser object', () => { + const req = { ombiUser: { normalizedUserName: 'norm_ombi' } }; + expect(extractRequestedUser(req)).toBe('norm_ombi'); + }); + + it('returns userAlias from nested requestedByUser object', () => { + const req = { requestedByUser: { userAlias: 'alias_user' } }; + expect(extractRequestedUser(req)).toBe('alias_user'); + }); + + it('returns username from a string source value', () => { + const req = { requestedBy: 'direct_string' }; + expect(extractRequestedUser(req)).toBe('direct_string'); + }); + + it('returns username from root fallbacks (requestedByUsername, requester, requestedByEmail)', () => { + expect(extractRequestedUser({ requestedByUsername: 'user_uname' })).toBe('user_uname'); + expect(extractRequestedUser({ requester: 'req_val' })).toBe('req_val'); + expect(extractRequestedUser({ requestedByEmail: 'test@email.com' })).toBe('test@email.com'); + }); + + it('recursively extracts user from seasons array requests', () => { + const req = { + seasons: [ + {}, + { requestedUser: { alias: 'season_user' } } + ] + }; + expect(extractRequestedUser(req)).toBe('season_user'); + }); + + it('recursively extracts user from childRequests array', () => { + const req = { + childRequests: [ + {}, + { user: { userName: 'child_user' } } + ] + }; + expect(extractRequestedUser(req)).toBe('child_user'); + }); }); describe('filterRequestsByUser', () => {